From 0d9e05e2f0b9643d07cd127e14173088e4002282 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann <44057549+OleWunschmann@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:19:44 +0200 Subject: [PATCH 01/34] Enhance customALGoFiles feature (#3) --- Actions/.Modules/ReadSettings.psm1 | 1 + Actions/.Modules/settings.schema.json | 27 + .../CheckForUpdates.HelperFunctions.ps1 | 226 ++++- Actions/CheckForUpdates/CheckForUpdates.ps1 | 71 +- RELEASENOTES.md | 10 + Scenarios/CustomizingALGoForGitHub.md | 93 +- Scenarios/settings.md | 2 +- Tests/CheckForUpdates.Action.Test.ps1 | 945 ++++++++++++++++-- e2eTests/scenarios/CustomTemplate/runtest.ps1 | 228 ++++- 9 files changed, 1426 insertions(+), 177 deletions(-) diff --git a/Actions/.Modules/ReadSettings.psm1 b/Actions/.Modules/ReadSettings.psm1 index ec6dc55885..4fd0a84787 100644 --- a/Actions/.Modules/ReadSettings.psm1 +++ b/Actions/.Modules/ReadSettings.psm1 @@ -260,6 +260,7 @@ function GetDefaultSettings "customALGoFiles" = [ordered]@{ "filesToInclude" = @() "filesToExclude" = @() + "filesToRemove" = @() } "postponeProjectInBuildOrder" = $false } diff --git a/Actions/.Modules/settings.schema.json b/Actions/.Modules/settings.schema.json index 8cf29ab0d0..37554a8b7d 100644 --- a/Actions/.Modules/settings.schema.json +++ b/Actions/.Modules/settings.schema.json @@ -739,6 +739,7 @@ "type": "object", "properties": { "filesToInclude": { + "description": "An array of file specifications to include in the update. Files that match these specifications are copied from the template to the repository. When used in a custom template's settings, inclusions are also propagated from the original template to consumer repos even if the files no longer exist in the custom template.", "type": "array", "items": { "type": "object", @@ -763,6 +764,7 @@ } }, "filesToExclude": { + "description": "An array of file specifications to exclude from the update. Files that match these specifications are not copied from the template to the repository. When used in a custom template's settings, exclusions are also propagated from the original template to consumer repos even if the files no longer exist in the custom template.", "type": "array", "items": { "type": "object", @@ -777,6 +779,31 @@ } } } + }, + "filesToRemove": { + "description": "An array of file specifications to unconditionally remove from the repository during 'Update AL-Go System Files', regardless of whether the files exist in the template. Useful for cleaning up files that have been removed from the template but may still exist in repositories.", + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceFolder": { + "type": "string", + "description": "The source folder from which to remove files, relative to the template repository root." + }, + "filter": { + "type": "string", + "description": "A filter string to select which files to remove. It can contain '*' and '?' wildcards." + }, + "destinationFolder": { + "type": "string", + "description": "The destination folder where the files should be removed, relative to the repository root." + }, + "perProject": { + "type": "boolean", + "description": "Indicates whether the removal should be applied per project. In that case, destinationFolder is relative to each project folder." + } + } + } } } }, diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 3482ce5d0c..7dc531c1ac 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -943,6 +943,91 @@ function ResolveFilePaths { return @($fullFilePaths) } +<# +.SYNOPSIS +Resolves file paths for files that already exist in the destination folder (e.g. files to remove). + +.DESCRIPTION +This function is a wrapper around ResolveFilePaths for resolving file specs that live in the destination +(target repo) folder rather than in a template folder (source and destination are the same folder). + +Before calling ResolveFilePaths, each file spec is normalized: +- destinationFolder (if specified) is used as the effective sourceFolder, because in the destination repo + the file lives at its destination path, not at the template-relative source path. +- destinationName (if specified) overrides the filter, because the file may have been renamed at the destination. +- perProject is cleared to $false on the normalized spec; per-project expansion is handled directly here. + +For files marked as perProject, the function iterates over all projects and calls ResolveFilePaths once per +project with a project-scoped folder (destinationFolder/project) as both source and destination. +For files not marked as perProject, ResolveFilePaths is called once with destinationFolder as both source +and destination. + +.PARAMETER destinationFolder +The folder that serves as both source and destination. Typically the root of the target repository. + +.PARAMETER files +An array of hashtables specifying the files to resolve. Each hashtable can contain the following keys: +- sourceFolder: The subfolder within the destination folder to search for files (default is current folder). + Note: if destinationFolder is specified, it takes precedence as the effective lookup folder. +- filter: The file filter to apply when searching for files (default is all files). + Note: if destinationName is specified, it overrides the filter. +- type: The type of the files (default is empty). +- destinationFolder: The subfolder within the destination folder where the files are located. Takes + precedence over sourceFolder as the effective lookup folder. +- destinationName: The expected filename in the destination folder. Overrides filter when specified. +- perProject: A boolean indicating whether the files are per project (default is false). + +.PARAMETER projects +An array of project names used when resolving per-project file paths. + +.OUTPUTS +An array of hashtables, each containing: +- sourceFullPath: The full path to the source file. +- originalSourceFullPath: Always $null (no original template folder in this context). +- type: The type of the file. +- destinationFullPath: The full path to the destination file. +#> +function ResolveFilePathsInDestinationFolder { + Param( + [Parameter(Mandatory=$true)] + [string] $destinationFolder, + [array] $files = @(), + [string[]] $projects = @() + ) + if(-not $files) { + return @() + } + + $fullFilePaths = @() + foreach($file in $files) { + $destinationFile = $file.Clone() + $destinationFile.perProject = $false + + # Replace sourceFolder with destinationFolder (if specified) since we are resolving against the destination folder + if($file.Keys -contains 'destinationFolder') { + $destinationFile.sourceFolder = $file.destinationFolder + } + # Replace filter with destinationName (if specified) since we are resolving against the destination folder + if($file.Keys -contains 'destinationName' -and ($file.destinationName)) { + $destinationFile.filter = $file.destinationName + } + + if ($file.Keys -contains 'perProject' -and $file.perProject -eq $true) { + foreach ($project in $projects) { + if ($project -eq '.') { + $project = '' # If project is '.', it means the root folder, so we use an empty string + } + $projectFolder = Join-Path $destinationFolder $project + $fullFilePaths += ResolveFilePaths -sourceFolder $projectFolder -destinationFolder $projectFolder -files @($destinationFile) + } + } else { + $fullFilePaths += ResolveFilePaths -sourceFolder $destinationFolder -destinationFolder $destinationFolder -files @($destinationFile) + } + } + + return @($fullFilePaths) +} + function GetDefaultFilesToInclude { Param( [switch] $includeCustomTemplateFiles @@ -993,27 +1078,44 @@ function GetDefaultFilesToExclude { <# .SYNOPSIS - Get the list of files from the template repository to include and exclude based on the provided settings. + Compute the lists of files to include, exclude, and remove when synchronizing a repository from its AL-Go template. .DESCRIPTION - This function gets the list of files to include and exclude based on the provided settings. - The unusedALGoSystemFiles setting is also applied to exclude files from the include list and add them to the exclude list. + Builds three lists by merging defaults, repository settings, template settings, and the original AL-Go template (if given): + + 1. filesToInclude: Files to copy from the template or original template to the destination. + Built from default files to include and customALGoFiles.filesToInclude in settings and templateSettings, resolved against the template folder and original template folder (if any). + 2. filesToExclude: Files to skip from copying; if they already exist in the destination they should be deleted. + Built from default files to exclude, customALGoFiles.filesToExclude, and customALGoFiles.filesToRemove in settings and templateSettings, resolved against the template folder and original template folder (if any). + 3. filesToRemove: Files to unconditionally delete from the destination. + Built from customALGoFiles.filesToRemove in settings and templateSettings and resolved against the destination folder (not the template folder). + + The deprecated unusedALGoSystemFiles setting is also applied: matching files are moved from filesToInclude to + filesToExclude with a deprecation warning. .PARAMETER settings - The settings object containing the customALGoFiles configuration. + The settings object containing customALGoFiles (filesToInclude, filesToExclude, filesToRemove) and the + deprecated unusedALGoSystemFiles configuration. .PARAMETER baseFolder The base folder of the repository. This is the target folder where the files will be updated. .PARAMETER templateFolder The folder where the template files are located. +.PARAMETER templateSettings + The settings object from the template repository (if any). Both customALGoFiles and unusedALGoSystemFiles + are merged from this object when it is provided. .PARAMETER originalTemplateFolder - The folder where the original template files are located (if any). - If originalTemplateFolder is provided, it means that there is a custom template in use and custom template files should be included. + The folder where the original AL-Go template files are located (if any). + When provided, it signals that a custom template is in use. Both filesToInclude and filesToExclude specs are + resolved against this folder in addition to templateFolder; entries not already covered by originalSourceFullPath + tracking are appended to propagate upstream template additions and deletions to consumer repositories. .PARAMETER projects The list of projects in the repository. The projects are used to resolve per-project files. .OUTPUTS - An array containing two elements: the list of files to include and the list of files to exclude. - Files are represented as hashtables with the following keys: - - sourceFullPath: The full path to the source file in the template repository. + An array containing three elements: the list of files to include, the list of files to exclude, and the list of + files to remove. + All entries are hashtables with the following keys: + - sourceFullPath: The full path to the source file. - originalSourceFullPath: The full path to the original source file in the original template repository (if any). + Always $null for filesToRemove entries. - type: The type of the file (e.g., workflow, settings). - destinationFullPath: The full path to the destination file in the target repository. #> @@ -1025,50 +1127,109 @@ function GetFilesToUpdate { $baseFolder, [Parameter(Mandatory=$true)] $templateFolder, + $templateSettings = $null, $originalTemplateFolder = $null, $projects = @() ) + $hasOriginalTemplate = $null -ne $originalTemplateFolder Write-Host "Getting files to update from template folder '$templateFolder', original template folder '$originalTemplateFolder' and base folder '$baseFolder'" # Send telemetery about customALGoFiles usage if ($settings.customALGoFiles.filesToInclude.Count -gt 0) { - Trace-Information -Message "Usage: Custom AL-Go Files (Include)" + Trace-Information -Message "Usage: Custom AL-Go Files (Include) of repository" } if ($settings.customALGoFiles.filesToExclude.Count -gt 0) { - Trace-Information -Message "Usage: Custom AL-Go Files (Exclude)" + Trace-Information -Message "Usage: Custom AL-Go Files (Exclude) of repository" + } + if ($settings.customALGoFiles.filesToRemove.Count -gt 0) { + Trace-Information -Message "Usage: Custom AL-Go Files (Remove) of repository" + } + + if ($null -ne $templateSettings) { + if ($templateSettings.customALGoFiles.filesToInclude.Count -gt 0) { + Trace-Information -Message "Usage: Custom AL-Go Files (Include) of template" + } + if ($templateSettings.customALGoFiles.filesToExclude.Count -gt 0) { + Trace-Information -Message "Usage: Custom AL-Go Files (Exclude) of template" + } + if ($templateSettings.customALGoFiles.filesToRemove.Count -gt 0) { + Trace-Information -Message "Usage: Custom AL-Go Files (Remove) of template" + } } - $filesToInclude = GetDefaultFilesToInclude -includeCustomTemplateFiles:$($null -ne $originalTemplateFolder) - $filesToInclude += $settings.customALGoFiles.filesToInclude - $filesToInclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToInclude -projects $projects) + # Determine files to include + $filesToIncludeUnresolved = GetDefaultFilesToInclude -includeCustomTemplateFiles:$hasOriginalTemplate + if ($null -ne $templateSettings) { + $filesToIncludeUnresolved += $templateSettings.customALGoFiles.filesToInclude + } + $filesToIncludeUnresolved += $settings.customALGoFiles.filesToInclude + $filesToInclude = @() + if ($hasOriginalTemplate) { + $filesToInclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) + } + $filesToInclude += @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) + # Remove duplicates based on destinationFullPath, keeping the last one (setting > template settings > defaults; template folder > original template folder) + $filesToInclude = @($filesToInclude | Group-Object -Property destinationFullPath | ForEach-Object { $_.Group[-1] }) - $filesToExclude = GetDefaultFilesToExclude -settings $settings - $filesToExclude += $settings.customALGoFiles.filesToExclude - $filesToExclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExclude -projects $projects) + # Determine files to exclude + $filesToExcludeUnresolved = GetDefaultFilesToExclude -settings $settings + if ($null -ne $templateSettings) { + $filesToExcludeUnresolved += $templateSettings.customALGoFiles.filesToExclude + } + $filesToExcludeUnresolved += $settings.customALGoFiles.filesToExclude + $filesToExclude = @() + if ($hasOriginalTemplate) { + $filesToExclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) + } + $filesToExclude += @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) + # Remove duplicates based on destinationFullPath, keeping the last one (setting > template settings > defaults; template folder > original template folder) + $filesToExclude = @($filesToExclude | Group-Object -Property destinationFullPath | ForEach-Object { $_.Group[-1] }) - # Exclude files from filesToExclude that are not in filesToInclude - $filesToExclude = @($filesToExclude | Where-Object { - $fileToExclude = $_ - $include = $filesToInclude | Where-Object { $_.sourceFullPath -eq $fileToExclude.sourceFullPath } - if(-not $include) { - OutputDebug "Excluding file $($fileToExclude.sourceFullPath) from exclude list as it is not in the include list" - } + # Determine files to remove + $filesToRemoveUnresolved = @() + if ($null -ne $templateSettings) { + $filesToRemoveUnresolved += $templateSettings.customALGoFiles.filesToRemove + } + $filesToRemoveUnresolved += $settings.customALGoFiles.filesToRemove + $filesToRemove = @() + if ($hasOriginalTemplate) { + $filesToRemove += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) + } + $filesToRemove += @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) + $filesToRemove += @(ResolveFilePathsInDestinationFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) + # Remove duplicates based on destinationFullPath, keeping the last one (settings > template settings; base folder > template folder > original template folder) + $filesToRemove = @($filesToRemove | Group-Object -Property destinationFullPath | ForEach-Object { $_.Group[-1] }) + + # Exclude files from filesToInclude that are in filesToRemove (based on destination) + $filesToInclude = @($filesToInclude | Where-Object { + $fileToInclude = $_ + $include = -not ($filesToRemove | Where-Object { $_.destinationFullPath -eq $fileToInclude.destinationFullPath }) + if (-not $include) { OutputDebug "Excluding destination file '$($fileToInclude.destinationFullPath)' from include list as it is in the remove list" } return $include }) - # Exclude files from filesToInclude that are in filesToExclude - $filesToInclude = @($filesToInclude | Where-Object { + # Map files from filesToExclude to files that are in filesToInclude (based on source) + # Settings for filesToExclude only define the sources (sourceFolder and filter) but not the destinations (destinationFolder, destinationName and perProject) + $filesToExclude = @($filesToInclude | Where-Object { $fileToInclude = $_ - $include = -not ($filesToExclude | Where-Object { $_.sourceFullPath -eq $fileToInclude.sourceFullPath }) - if(-not $include) { - OutputDebug "Excluding file $($fileToInclude.sourceFullPath) from include as it is in the exclude list" - } + return $filesToExclude | Where-Object { $_.sourceFullPath -eq $fileToInclude.sourceFullPath } + }) + + # Exclude files from filesToInclude that are in filesToExclude (based on source) + $filesToInclude = @($filesToInclude | Where-Object { + $file = $_ + $include = -not ($filesToExclude | Where-Object { $_.sourceFullPath -eq $file.sourceFullPath }) + if (-not $include) { OutputDebug "Excluding source file '$($file.sourceFullPath)' from include list as it is in the exclude list" } return $include }) # Apply unusedALGoSystemFiles logic - $unusedALGoSystemFiles = $settings.unusedALGoSystemFiles + # Include unusedALGoSystemFiles from the template settings (if any) to also propagate template's deprecated file removals + $unusedALGoSystemFiles = @($settings.unusedALGoSystemFiles) + if ($null -ne $templateSettings) { + $unusedALGoSystemFiles += @($templateSettings.unusedALGoSystemFiles) + } # Exclude unusedALGoSystemFiles from $filesToInclude and add them to $filesToExclude $unusedFilesToExclude = $filesToInclude | Where-Object { $unusedALGoSystemFiles -contains (Split-Path -Path $_.sourceFullPath -Leaf) } @@ -1086,6 +1247,7 @@ function GetFilesToUpdate { $fileFormatter = { param($file) " -Source: $($file.sourceFullPath), Destination: $($file.destinationFullPath), Type: $($file.type), Original Source: $($file.originalSourceFullPath)"} OutputArray -Message "Files to include: $($filesToInclude.Count)" -Array $filesToInclude -Formatter $fileFormatter OutputArray -Message "Files to exclude: $($filesToExclude.Count)" -Array $filesToExclude -Formatter $fileFormatter + OutputArray -Message "Files to remove: $($filesToRemove.Count)" -Array $filesToRemove -Formatter $fileFormatter - return @($filesToInclude), @($filesToExclude) + return @($filesToInclude), @($filesToExclude), @($filesToRemove) } diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index 46a95da706..bef77b9a4d 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -52,7 +52,7 @@ if ($token) { # if $downloadLatest is set to true, CheckForUpdates will download the latest version of the template repository, else it will use the templateSha setting in the .github/AL-Go-Settings file # Get Repo settings as a hashtable (do NOT read any specific project settings, nor any specific workflow, user or branch settings) -$repoSettings = ReadSettings -buildMode '' -project '' -workflowName '' -userName '' -branchName '' | ConvertTo-HashTable -recurse +$repoSettings = ReadSettings -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse $templateSha = $repoSettings.templateSha # If templateUrl has changed, download latest version of the template repository (ignore templateSha) @@ -61,6 +61,7 @@ if ($repoSettings.templateUrl -ne $templateUrl -or $templateSha -eq '') { } $originalTemplateFolder = $null +$templateRepoSettings = $null $templateFolder = DownloadTemplateRepository -token $token -templateUrl $templateUrl -templateSha ([ref]$templateSha) -downloadLatest $downloadLatest $templateFolder = GetSrcFolder -repoType $repoSettings.type -templateUrl $templateUrl -templateFolder $templateFolder Write-Host "Template Folder: $templateFolder" @@ -71,42 +72,40 @@ $templateInfo = "$templateOwner/$($templateUrl.Split('/')[4])" $isDirectALGo = IsDirectALGo -templateUrl $templateUrl if (-not $isDirectALGo) { - $templateRepoSettingsFile = Join-Path $templateFolder $RepoSettingsFile - if (Test-Path -Path $templateRepoSettingsFile -PathType Leaf) { - $templateRepoSettings = Get-Content $templateRepoSettingsFile -Encoding UTF8 | ConvertFrom-Json | ConvertTo-HashTable -Recurse - if ($templateRepoSettings.Keys -contains "templateUrl" -and $templateRepoSettings.templateUrl -ne $templateUrl) { - # The template repository is a url to another AL-Go repository (a custom template repository) - Trace-Information -Message "Using custom AL-Go template repository" - - # TemplateUrl and TemplateSha from .github/AL-Go-Settings.json in the custom template repository points to the "original" template repository - # Copy files and folders from the custom template repository, but grab the unmodified file from the "original" template repository if it exists and apply customizations - # Copy .github/AL-Go-Settings.json to .github/templateRepoSettings.doNotEdit.json (will be read before .github/AL-Go-Settings.json in the final repo) - # Copy .AL-Go/settings.json to .github/templateProjectSettings.doNotEdit.json (will be read before .AL-Go/settings.json in the final repo) - - Write-Host "Custom AL-Go template repository detected, downloading the 'original' template repository" - $originalTemplateUrl = $templateRepoSettings.templateUrl - if ($templateRepoSettings.Keys -contains "templateSha") { - $originalTemplateSha = $templateRepoSettings.templateSha - } - else { - $originalTemplateSha = "" - } + # Get template Repo settings as a hashtable (do NOT read any variable settings, specific project settings, nor any specific workflow, user or branch settings) + $templateRepoSettings = ReadSettings -baseFolder $templateFolder -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' -orgSettingsVariableValue '' -repoSettingsVariableValue '' -environmentSettingsVariableValue '' | ConvertTo-HashTable -recurse + if ($templateRepoSettings.templateUrl -and $templateRepoSettings.templateUrl -ne $templateUrl) { + # The template repository is a url to another AL-Go repository (a custom template repository) + Trace-Information -Message "Using custom AL-Go template repository" + + # TemplateUrl and TemplateSha from .github/AL-Go-Settings.json in the custom template repository points to the "original" template repository + # Copy files and folders from the custom template repository, but grab the unmodified file from the "original" template repository if it exists and apply customizations + # Copy .github/AL-Go-Settings.json to .github/templateRepoSettings.doNotEdit.json (will be read before .github/AL-Go-Settings.json in the final repo) + # Copy .AL-Go/settings.json to .github/templateProjectSettings.doNotEdit.json (will be read before .AL-Go/settings.json in the final repo) + + Write-Host "Custom AL-Go template repository detected, downloading the 'original' template repository" + $originalTemplateUrl = $templateRepoSettings.templateUrl + if ($templateRepoSettings.Keys -contains "templateSha") { + $originalTemplateSha = $templateRepoSettings.templateSha + } + else { + $originalTemplateSha = "" + } - # Download the "original" template repository - use downloadLatest if no TemplateSha is specified in the custom template repository - $originalTemplateFolder = DownloadTemplateRepository -token $token -templateUrl $originalTemplateUrl -templateSha ([ref]$originalTemplateSha) -downloadLatest ($originalTemplateSha -eq '') - $originalTemplateFolder = GetSrcFolder -repoType $repoSettings.type -templateUrl $originalTemplateUrl -templateFolder $originalTemplateFolder + # Download the "original" template repository - use downloadLatest if no TemplateSha is specified in the custom template repository + $originalTemplateFolder = DownloadTemplateRepository -token $token -templateUrl $originalTemplateUrl -templateSha ([ref]$originalTemplateSha) -downloadLatest ($originalTemplateSha -eq '') + $originalTemplateFolder = GetSrcFolder -repoType $repoSettings.type -templateUrl $originalTemplateUrl -templateFolder $originalTemplateFolder - Write-Host "Original Template Folder: $originalTemplateFolder" + Write-Host "Original Template Folder: $originalTemplateFolder" - # Set TemplateBranch and TemplateOwner - # Keep TemplateUrl and TemplateSha pointing to the custom template repository - $templateBranch = $originalTemplateUrl.Split('@')[1] - $templateOwner = $originalTemplateUrl.Split('/')[3] + # Set TemplateBranch and TemplateOwner + # Keep TemplateUrl and TemplateSha pointing to the custom template repository + $templateBranch = $originalTemplateUrl.Split('@')[1] + $templateOwner = $originalTemplateUrl.Split('/')[3] - $isDirectALGo = IsDirectALGo -templateUrl $originalTemplateUrl - if ($isDirectALGo) { - Trace-Information -Message "Original template repository is direct AL-Go" - } + $isDirectALGo = IsDirectALGo -templateUrl $originalTemplateUrl + if ($isDirectALGo) { + Trace-Information -Message "Original template repository is direct AL-Go" } } } @@ -115,7 +114,7 @@ if (-not $isDirectALGo) { $baseFolder = $ENV:GITHUB_WORKSPACE $projects = @(GetProjectsFromRepository -baseFolder $baseFolder -projectsFromSettings $repoSettings.projects) -$filesToInclude, $filesToExclude = GetFilesToUpdate -settings $repoSettings -projects $projects -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder +$filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $repoSettings -projects $projects -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateRepoSettings -originalTemplateFolder $originalTemplateFolder # $updateFiles will hold an array of files, which needs to be updated $updateFiles = @() @@ -203,8 +202,8 @@ foreach($fileToInclude in $filesToInclude) { } Push-Location -Path $baseFolder -# Remove files that are in $filesToExclude and exist in the repository -$removeFiles = $filesToExclude | Where-Object { $_ -and (Test-Path -Path $_.destinationFullPath -PathType Leaf) } | ForEach-Object { +# Remove files that are in $filesToExclude or $filesToRemove and exist in the repository +$removeFiles = @($filesToExclude) + @($filesToRemove) | Where-Object { $_ -and (Test-Path -Path $_.destinationFullPath -PathType Leaf) } | ForEach-Object { $relativePath = Resolve-Path -Path $_.destinationFullPath -Relative Write-Host "File marked for removal: $relativePath" $relativePath diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 007110df8f..e29c5f755f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,3 +1,13 @@ +### Enhanced `customALGoFiles` setting + +The `customALGoFiles` setting of a custom template was only applied on the next Update (from `AL-Go-TemplateRepoSettings.doNotEdit.json`). Now the up-to-date settings of the custom template are used directly during "Update AL-Go System Files". The template's `filesToInclude`, `filesToExclude`, and `filesToRemove` settings are merged with the consumer repo's settings before resolution. + +- **`filesToInclude`** now also resolves files from the original AL-Go template. Files present in the official template that are not overridden by your custom template are propagated to consumer repos. When a file exists in both, the custom template version takes precedence. +- **`filesToExclude`** now also resolves files from the original AL-Go template (same dual-resolution as `filesToInclude`). Files resolved by `filesToInclude` whose source matches a `filesToExclude` entry are not copied to consumer repos, and existing copies are removed. +- **`filesToRemove`** (new property): Unconditionally removes matching files from consumer repos. Files are searched in both the template and end repository. Takes precedence over `filesToInclude`. Entries use `sourceFolder` (relative to the template), `filter`, and optionally `destinationFolder` and `perProject`. + +Read more at [Customizing AL-Go for GitHub](Scenarios/CustomizingALGoForGitHub.md#Using-custom-template-files). + ### Use artifact manifest to pick .NET runtime for assembly probing When compiling apps with the workspace compiler, AL-Go now reads the `dotNetVersion` from the BC artifact's `manifest.json` (copied into the compiler folder by BcContainerHelper) and selects an installed .NET runtime whose major version matches. This avoids version drift between the build agent's highest installed runtime and the platform the artifact was built against. If the manifest does not declare a `dotNetVersion`, or no installed runtime matches the required major, versioned .NET assembly probing paths are omitted (a warning is logged in the latter case). diff --git a/Scenarios/CustomizingALGoForGitHub.md b/Scenarios/CustomizingALGoForGitHub.md index f5d80b24ad..a3f8738178 100644 --- a/Scenarios/CustomizingALGoForGitHub.md +++ b/Scenarios/CustomizingALGoForGitHub.md @@ -231,7 +231,7 @@ Repositories based on your custom template will notify you that changes are avai When updating AL-Go for GitHub, only specific system files from the template repository are synced to your end repository by default. Files such as `README.md`, `.gitignore`, and other documentation or non-system files are not updated by AL-Go for GitHub. By default, AL-Go syncs workflow files in `.github/workflows`, PowerShell scripts in `.github` and `.AL-Go`, and configuration files required for AL-Go operations. When using custom template repositories, you may need to add additional files related to AL-Go for GitHub, such as script overrides, complementary workflows, or centrally managed files not part of the official AL-Go templates. -In order to instruct AL-Go which files to look for at the template repository, you need to define the `customALGoFiles` setting. The setting is an object that can contain two properties: `filesToInclude` and `filesToExclude`. +In order to instruct AL-Go which files to look for at the template repository, you need to define the `customALGoFiles` setting. The setting is an object that can contain three properties: `filesToInclude`, `filesToExclude`, and `filesToRemove`. `filesToInclude`, as the name suggests, is an array of file configurations that will instruct AL-Go which files to include (create/update). Every item in the array may contain the following properties: @@ -243,6 +243,17 @@ In order to instruct AL-Go which files to look for at the template repository, y > [!NOTE] > `filesToInclude` is used to define all the template files that will be used by AL-Go for GitHub. If a template file is not matched, it will be ignored. Please pay attention, when changing the file configurations: there might be template files that were previously propagated to your repositories. In case these files are no longer matched via `filesToInclude`, AL-Go for GitHub will ignore them and you might have to remove them manually. +When using a custom template repository, `filesToInclude` also resolves files from the **original** AL-Go template (i.e. the official [AL-Go-PTE](https://github.com/microsoft/AL-Go-PTE) or [AL-Go-AppSource](https://github.com/microsoft/AL-Go-AppSource) template). This means files present in the official AL-Go template that are not overridden by your custom template are still propagated to consumer repositories. When a file exists in both the original template and your custom template, the custom template version takes precedence. + +The following table summarizes how `filesToInclude` resolves files when a custom template is in use: + +| File is present in original template | File is present in custom template | File is matched by `filesToInclude` | Result | +|---|---|---|---| +| Yes | No | Yes | File from **original template** is propagated | +| No | Yes | Yes | File from **custom template** is propagated | +| Yes | Yes | Yes | File from **custom template** is used (takes precedence) | +| Yes/No | Yes/No | No | File is **ignored** | + `filesToExclude` is an array of file configurations that will instruct AL-Go which files to exclude (remove) from `filesToInclude`. Every item in the array may contain the following properties: - `sourceFolder`: A path to a folder, relative to the template, where to look for files. If not specified the root folder is implied. _Example_: `src/scripts`. @@ -251,18 +262,39 @@ In order to instruct AL-Go which files to look for at the template repository, y > [!NOTE] `filesToExclude` is an array of file configurations already included in `filesToInclude`. These files are specifically marked to be excluded from the update process. > This mechanism allows for fine-grained control over which files are propagated to the end repository and which should be explicitly removed, ensuring that unwanted files are not carried forward during updates. +> [!TIP] +> When using a custom template repository, you can use `filesToExclude` in the custom template's settings to prevent files from the original AL-Go template from being propagated to consumer repos. For example, if the original template includes a workflow you don't want in your consumer repos, adding it to `filesToExclude` in your custom template's settings will remove it during the next update. + The following table summarizes how AL-Go for GitHub manages file updates and exclusions when using custom template files. Say, there is a file (e.g. `file.ps1`) in the template repository. | File is present in end repo | File is matched by `filesToInclude` | File is matched by `filesToExclude` | Result | |---|---|---|---| | Yes/No | Yes | No | The file is **updated/created** in the end repo | | Yes | Yes | Yes | The file is **removed** from the end repo, as it's matched for exclusion | -| Yes | No | Yes | The files is **_not_** removed as it was not matched as update | +| Yes | No | Yes | The file is **_not_** removed as it was not matched as update | | No | Yes/No | Yes | The file is **_not_ created** in the end repo, as it's matched for exclusion | +`filesToRemove` is an array of file configurations that will instruct AL-Go which files to unconditionally remove from the end repository. Unlike `filesToExclude`, files matched by `filesToRemove` do not need to be part of `filesToInclude` first — they are removed regardless of whether they are included in the update process. Files are searched in both the template repository and the end repository. Every item in the array may contain the following properties: + +- `sourceFolder`: A path to a folder, relative to the template, where to look for files. If not specified the root folder is implied. `*` characters are not supported. _Example_: `.github/workflows`. +- `filter`: A string to use for filtering in the specified source path. It can contain `*` and `?` wildcards. _Example_: `deprecated-*.yaml` or `oldScript.ps1`. +- `destinationFolder`: A path to a folder, relative to the end repository, where the files are located. If not specified, defaults to the same as the source file folder. _Example_: `.github/workflows`. +- `perProject`: A boolean that indicates whether the matched files should be removed for all available AL-Go projects. In that case, `destinationFolder` is relative to the project folder. _Example_: `.AL-Go/scripts`. + +> [!NOTE] +> `filesToRemove` takes precedence over both `filesToInclude` and `filesToExclude`. If a file is matched by `filesToRemove`, it will be removed from the end repository even if it is also matched by `filesToInclude`. The file will also be excluded from the `filesToInclude` and `filesToExclude` lists, so it will not be created or updated. + +The following table summarizes how AL-Go for GitHub manages file removals when using `filesToRemove`: + +| File is present in end repo | File is matched by `filesToInclude` | File is matched by `filesToExclude` | File is matched by `filesToRemove` | Result | +|---|---|---|---|---| +| Yes/No | Yes/No | Yes/No | No | See `filesToInclude`/`filesToExclude` behavior above | +| Yes | Yes/No | Yes/No | Yes | The file is **removed** from the end repo (remove wins over include/exclude) | +| No | Yes/No | Yes/No | Yes | The file is **not created** in the end repo (excluded from `filesToInclude`/`filesToExclude`) | + ### Examples of using custom template files -Below are examples of how to use the `filesToInclude` and `filesToExclude` settings in your AL-Go configuration. +Below are examples of how to use the `filesToInclude`, `filesToExclude`, and `filesToRemove` settings in your AL-Go configuration. #### Example 1: Updating specific scripts for all projects @@ -348,6 +380,61 @@ Note that AL-Go for GitHub already syncs all workflow files under `.github/workf This configuration updates all JSON files from `shared/config` and all PowerShell scripts from `.github/scripts`, but excludes `legacy-config.json` from being updated or created. +#### Example 5: Removing a deprecated workflow from all consumer repos + +```json +"customALGoFiles": { + "filesToRemove": [ + { + "sourceFolder": ".github/workflows", + "filter": "deprecated-workflow.yaml" + } + ] +} +``` + +This configuration will remove `deprecated-workflow.yaml` from the `.github/workflows` folder in all consumer repositories, regardless of whether it is still present in the template. This is useful when a workflow has been retired and should be cleaned up from all repositories. + +#### Example 6: Removing per-project scripts that are no longer needed + +```json +"customALGoFiles": { + "filesToRemove": [ + { + "sourceFolder": ".AL-Go/scripts", + "filter": "OldBuildScript.ps1", + "perProject": true + } + ] +} +``` + +This will remove `OldBuildScript.ps1` from the `.AL-Go/scripts` folder of every AL-Go project in the target repository. + +#### Example 7: Combining `filesToInclude` with `filesToRemove` for migration + +```json +"customALGoFiles": { + "filesToInclude": [ + { + "sourceFolder": ".github/scripts", + "filter": "NewBuildHelper.ps1", + "destinationFolder": ".AL-Go/scripts", + "perProject": true + } + ], + "filesToRemove": [ + { + "sourceFolder": ".AL-Go/scripts", + "filter": "LegacyBuildHelper.ps1", + "perProject": true + } + ] +} +``` + +This configuration introduces a new script (`NewBuildHelper.ps1`) to all projects while simultaneously removing the old script (`LegacyBuildHelper.ps1`) it replaces. This pattern is useful for migrating from one file to another across all consumer repositories. + These examples demonstrate how you can fine-tune which files are propagated from your template repository and which are excluded, giving you granular control over your AL-Go customization process. ## Forking AL-Go for GitHub and making your "own" **public** version diff --git a/Scenarios/settings.md b/Scenarios/settings.md index 6ba69f6d9a..69987b592a 100644 --- a/Scenarios/settings.md +++ b/Scenarios/settings.md @@ -247,7 +247,7 @@ Please read the release notes carefully when installing new versions of AL-Go fo | BcContainerHelperVersion | This setting can be set to a specific version (ex. 3.0.8) of BcContainerHelper to force AL-Go to use this version. **latest** means that AL-Go will use the latest released version. **preview** means that AL-Go will use the latest preview version. **dev** means that AL-Go will use the dev branch of containerhelper. | latest (or preview for AL-Go preview) | | unusedALGoSystemFiles (**deprecated**) | An array of AL-Go System Files, which won't be updated during Update AL-Go System Files. They will instead be removed.
Use this setting with care, as this can break the AL-Go for GitHub functionality and potentially leave your repo no longer functional. | [ ] | | reportSuppressedDiagnostics | If this setting is set to true, the AL compiler will report diagnostics which are suppressed in the code using the pragma `#pragma warning disable `. This can be useful if you want to ensure that no warnings are suppressed in your code. | false | -| customALGoFiles | An object to configure custom AL-Go files, that will be updated during "Update AL-Go System Files" workflow. The object can contain properties `filesToInclude` and `filesToExclude`. Read more at [Customizing AL-Go](CustomizingALGoForGitHub.md#Using-custom-template-files). | `{ "filesToInclude": [], "filesToExclude": [] }` +| customALGoFiles | An object to configure custom AL-Go files, that will be updated during "Update AL-Go System Files" workflow. The object can contain properties `filesToInclude`, `filesToExclude`, and `filesToRemove`. Read more at [Customizing AL-Go](CustomizingALGoForGitHub.md#Using-custom-template-files). | `{ "filesToInclude": [], "filesToExclude": [], "filesToRemove": [] }` ## Overwrite settings diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index be6cfa9b98..1777c840af 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -1808,6 +1808,181 @@ Describe "ResolveFilePaths" { } } +Describe "ResolveFilePathsInDestinationFolder" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'destinationFolder', Justification = 'False positive.')] + $destinationFolder = Join-Path $PSScriptRoot "destResolveFolder" + + New-Item -Path (Join-Path $destinationFolder "folder/File1.txt") -ItemType File -Force | Out-Null + New-Item -Path (Join-Path $destinationFolder "folder/File2.log") -ItemType File -Force | Out-Null + New-Item -Path (Join-Path $destinationFolder "folder/File3.txt") -ItemType File -Force | Out-Null + New-Item -Path (Join-Path $destinationFolder "folder/File4.md") -ItemType File -Force | Out-Null + New-Item -Path (Join-Path $destinationFolder "project1/folder/File1.txt") -ItemType File -Force | Out-Null + New-Item -Path (Join-Path $destinationFolder "project2/folder/File1.txt") -ItemType File -Force | Out-Null + + # File tree: + # destResolveFolder/ + # ├── folder/ + # │ ├── File1.txt + # │ ├── File2.log + # │ ├── File3.txt + # │ └── File4.md + # └── project1/ + # └── folder/ + # └── File1.txt + # └── project2/ + # └── folder/ + # └── File1.txt + } + + AfterAll { + if (Test-Path $destinationFolder) { + Remove-Item -Path $destinationFolder -Recurse -Force + } + } + + It 'Returns files matching filter in sourceFolder relative to destinationFolder' { + $files = @(@{ sourceFolder = "folder"; filter = "*.txt" }) + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 2 + $result.sourceFullPath | Should -Contain (Join-Path $destinationFolder "folder/File1.txt") + $result.sourceFullPath | Should -Contain (Join-Path $destinationFolder "folder/File3.txt") + $result.destinationFullPath | Should -Contain (Join-Path $destinationFolder "folder/File1.txt") + $result.destinationFullPath | Should -Contain (Join-Path $destinationFolder "folder/File3.txt") + } + + It 'destinationFolder key overrides sourceFolder for lookup' { + $files = @(@{ sourceFolder = "folder"; filter = "File1.txt"; destinationFolder = "project1/folder" }) + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 1 + $result[0].sourceFullPath | Should -Be (Join-Path $destinationFolder "project1/folder/File1.txt") + $result[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "project1/folder/File1.txt") + } + + It 'destinationName key overrides filter for filename lookup' { + $files = @(@{ sourceFolder = "folder"; filter = "File2.log"; destinationName = "File1.txt" }) + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 1 + $result[0].sourceFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") + $result[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") + } + + It 'Both destinationFolder and destinationName combined resolve the correct file' { + $files = @(@{ sourceFolder = "folder"; filter = "File2.log"; destinationFolder = "project1/folder"; destinationName = "File1.txt" }) + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 1 + $result[0].sourceFullPath | Should -Be (Join-Path $destinationFolder "project1/folder/File1.txt") + $result[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "project1/folder/File1.txt") + } + + It 'perProject=true with a single project scopes results to that project subfolder' { + $files = @(@{ sourceFolder = "folder"; filter = "File1.txt"; perProject = $true }) + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files -projects @('project1')) + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 1 + $result[0].sourceFullPath | Should -Be (Join-Path $destinationFolder "project1/folder/File1.txt") + $result[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "project1/folder/File1.txt") + } + + It "perProject=true with project '.' maps to the root of destinationFolder" { + $files = @(@{ sourceFolder = "folder"; filter = "File1.txt"; perProject = $true }) + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files -projects @('.')) + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 1 + $result[0].sourceFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") + $result[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") + } + + It 'perProject=true with empty projects array returns empty result' { + $files = @(@{ sourceFolder = "folder"; filter = "*.txt"; perProject = $true }) + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files -projects @()) + + $result | Should -BeNullOrEmpty + } + + It 'Mixed perProject=true and perProject=false files in same call returns correct results' { + $files = @( + @{ sourceFolder = "folder"; filter = "*.log" } # non-perProject + @{ sourceFolder = "folder"; filter = "File1.txt"; perProject = $true } # perProject + ) + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files -projects @('project1')) + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 2 + $result.sourceFullPath | Should -Contain (Join-Path $destinationFolder "folder/File2.log") + $result.sourceFullPath | Should -Contain (Join-Path $destinationFolder "project1/folder/File1.txt") + } + + It 'perProject=true with multiple projects returns one entry per project' { + $files = @(@{ sourceFolder = "folder"; filter = "File1.txt"; perProject = $true }) + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files -projects @('project1', 'project2')) + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 2 + $result.sourceFullPath | Should -Contain (Join-Path $destinationFolder "project1/folder/File1.txt") + $result.sourceFullPath | Should -Contain (Join-Path $destinationFolder "project2/folder/File1.txt") + } + + It 'originalSourceFullPath is always $null for all returned entries' { + $files = @(@{ sourceFolder = "folder" }) + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) + + $result | Should -Not -BeNullOrEmpty + foreach ($entry in $result) { + $entry.originalSourceFullPath | Should -Be $null + } + } + + It 'Returns empty array when files parameter is $null' { + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $null) + + $result | Should -BeNullOrEmpty + } + + It 'Returns empty array when files parameter is an empty array' { + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files @()) + + $result | Should -BeNullOrEmpty + } + + It 'Returns empty array when no files match the filter' { + $files = @(@{ sourceFolder = "folder"; filter = "*.nonexistent" }) + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) + + $result | Should -BeNullOrEmpty + } + + It 'Skips files outside the destinationFolder when sourceFolder traverses up' { + $externalFolder = Join-Path $PSScriptRoot "external" + New-Item -Path (Join-Path $externalFolder "external.txt") -ItemType File -Force | Out-Null + + try { + $files = @(@{ sourceFolder = "../external"; filter = "*.txt" }) + $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) + + # Should return no results since ../external is outside destinationFolder + $result | Should -BeNullOrEmpty + } + finally { + if (Test-Path $externalFolder) { Remove-Item -Path $externalFolder -Recurse -Force } + } + } +} + Describe "ReplaceOwnerRepoAndBranch" { BeforeAll { $actionName = "CheckForUpdates" @@ -1877,15 +2052,84 @@ Describe "GetFilesToUpdate (general files to update logic)" { # . # ├── test.ps1 # ├── test.txt - # └── test2.txt + # ├── test2.txt + # └── subfolder + # ├── testsub.txt + # └── testsub2.txt + + $originalTemplateFolder = Join-Path $PSScriptRoot "originalTemplate" + Copy-Item -Path $templateFolder -Destination $originalTemplateFolder -Recurse -Force | Out-Null + + $testOriginalTemplateTxtFile = Join-Path $originalTemplateFolder "test.original.txt" + Set-Content -Path $testOriginalTemplateTxtFile -Value "test original template txt file" + + $testOriginalTemplatePSFile = Join-Path $originalTemplateFolder "test.original.ps1" + Set-Content -Path $testOriginalTemplatePSFile -Value "# test original template ps file" + + # Display the created files structure for original template folder + # . + # ├── test.ps1 + # ├── test.txt + # ├── test2.txt + # ├── test.original.ps1 + # ├── test.original.txt # └── subfolder - # └── testsub.txt + # ├── testsub.txt + # └── testsub2.txt + + $baseFolder = Join-Path $PSScriptRoot "base" + Copy-Item -Path $templateFolder -Destination $baseFolder -Recurse -Force | Out-Null + + $testBaseTxtFile = Join-Path $baseFolder "test.base.txt" + Set-Content -Path $testBaseTxtFile -Value "test base txt file" + + $testBasePSFile = Join-Path $baseFolder "test.base.ps1" + Set-Content -Path $testBasePSFile -Value "# test base ps file" + + $baseProject1Folder = Join-Path $baseFolder "project1" + Copy-Item -Path $templateFolder -Destination $baseProject1Folder -Recurse -Force | Out-Null + + $baseProject2Folder = Join-Path $baseFolder "project2" + Copy-Item -Path $templateFolder -Destination $baseProject2Folder -Recurse -Force | Out-Null + + Remove-Item -Path (Join-Path $baseFolder 'test2.txt') -Recurse -Force | Out-Null + + # Display the created files structure for base folder + # . + # ├── test.ps1 + # ├── test.txt + # ├── test2.txt + # ├── test.base.ps1 + # ├── test.base.txt + # ├── subfolder + # │ ├── testsub.txt + # │ └── testsub2.txt + # ├── project1 + # │ ├── test.ps1 + # │ ├── test.txt + # │ ├── test2.txt + # │ └── subfolder + # │ ├── testsub.txt + # │ └── testsub2.txt + # └── project2 + # ├── test.ps1 + # ├── test.txt + # ├── test2.txt + # └── subfolder + # ├── testsub.txt + # └── testsub2.txt } AfterAll { if (Test-Path $templateFolder) { Remove-Item -Path $templateFolder -Recurse -Force } + if (Test-Path $originalTemplateFolder) { + Remove-Item -Path $originalTemplateFolder -Recurse -Force + } + if (Test-Path $baseFolder) { + Remove-Item -Path $baseFolder -Recurse -Force + } } It "Returns the correct files to update with filters" { @@ -1895,18 +2139,20 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.ps1" }) filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 $filesToInclude[0].sourceFullPath | Should -Be $testPSFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.ps1') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.ps1') - # No files to remove + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty + $filesToRemove | Should -BeNullOrEmpty $settings = @{ type = "NotPTE" @@ -1914,19 +2160,21 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt" }) filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 $filesToInclude[0].sourceFullPath | Should -Be $testTxtFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.txt') $filesToInclude[1].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'test2.txt') - # No files to remove + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty + $filesToRemove | Should -BeNullOrEmpty } It 'Returns the correct files with destinationFolder' { @@ -1936,20 +2184,22 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt"; destinationFolder = "customFolder" }) filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 $filesToInclude[0].sourceFullPath | Should -Be $testTxtFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'customFolder/test.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'customFolder/test.txt') $filesToInclude[1].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'customFolder/test2.txt') + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'customFolder/test2.txt') - # No files to remove + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty + $filesToRemove | Should -BeNullOrEmpty $settings = @{ type = "NotPTE" @@ -1957,21 +2207,22 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt"; destinationFolder = "customFolder" }) filesToExclude = @(@{ filter = "test2.txt" }) + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 $filesToInclude[0].sourceFullPath | Should -Be $testTxtFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'customFolder/test.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'customFolder/test.txt') # One file to remove $filesToExclude | Should -Not -BeNullOrEmpty $filesToExclude.Count | Should -Be 1 $filesToExclude[0].sourceFullPath | Should -Be $testTxtFile2 - $filesToExclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $filesToExclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'customFolder/test2.txt') } It 'Returns the correct files with destinationName' { @@ -1981,18 +2232,20 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "test.ps1"; destinationName = "renamed.txt" }) filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 $filesToInclude[0].sourceFullPath | Should -Be $testPSFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'renamed.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'renamed.txt') - # No files to remove + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty + $filesToRemove | Should -BeNullOrEmpty $settings = @{ type = "NotPTE" @@ -2000,15 +2253,16 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "test.ps1"; destinationFolder = 'dstPath'; destinationName = "renamed.txt" }) filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 $filesToInclude[0].sourceFullPath | Should -Be $testPSFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'dstPath/renamed.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'dstPath/renamed.txt') } It 'Return the correct files with types' { @@ -2018,19 +2272,21 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.ps1"; type = "script" }) filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 $filesToInclude[0].sourceFullPath | Should -Be $testPSFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.ps1') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.ps1') $filesToInclude[0].type | Should -Be "script" - # No files to remove + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty + $filesToRemove | Should -BeNullOrEmpty $settings = @{ type = "NotPTE" @@ -2038,22 +2294,23 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt"; type = "text" }) filesToExclude = @(@{ filter = "test.txt" }) + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 $filesToInclude[0].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test2.txt') $filesToInclude[0].type | Should -Be "text" # One file to remove $filesToExclude | Should -Not -BeNullOrEmpty $filesToExclude.Count | Should -Be 1 $filesToExclude[0].sourceFullPath | Should -Be $testTxtFile - $filesToExclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.txt') + $filesToExclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.txt') } It 'Return the correct files when unusedALGoSystemFiles is specified' { @@ -2063,23 +2320,24 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*" }) filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 $filesToInclude[0].sourceFullPath | Should -Be $testTxtFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.txt') $filesToInclude[1].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'test2.txt') # One file to remove $filesToExclude | Should -Not -BeNullOrEmpty $filesToExclude.Count | Should -Be 1 $filesToExclude[0].sourceFullPath | Should -Be $testPSFile - $filesToExclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.ps1') + $filesToExclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.ps1') } It 'GetFilesToUpdate with perProject true and empty projects returns no per-project entries' { @@ -2089,15 +2347,17 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt"; type = "text"; perProject = $true }) filesToExclude = @() + filesToRemove = @() } } # Pass empty projects array - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder -projects @() + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -projects @() # Behavior: when projects is empty, no per-project entries should be created $filesToInclude | Should -BeNullOrEmpty $filesToExclude | Should -BeNullOrEmpty + $filesToRemove | Should -BeNullOrEmpty } It 'GetFilesToUpdate ignores filesToExclude patterns that do not match any file' { @@ -2107,20 +2367,22 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt" }) filesToExclude = @(@{ filter = "no-match-*.none" }) + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # All txt files should be included, no files to exclude $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 $filesToInclude[0].sourceFullPath | Should -Be $testTxtFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.txt') $filesToInclude[1].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'test2.txt') $filesToExclude | Should -BeNullOrEmpty + $filesToRemove | Should -BeNullOrEmpty } It 'GetFilesToUpdate duplicates per-project includes for each project including the repository root' { @@ -2134,22 +2396,24 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "perProjectFile.algo"; perProject = $true; destinationFolder = 'custom' }) filesToExclude = @() + filesToRemove = @() } } $projects = @('.', 'ProjectOne') - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder -projects $projects + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -projects $projects $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 - $rootDestination = Join-Path 'baseFolder' 'custom/perProjectFile.algo' - $projectDestination = Join-Path 'baseFolder' 'ProjectOne/custom/perProjectFile.algo' + $rootDestination = Join-Path $baseFolder 'custom/perProjectFile.algo' + $projectDestination = Join-Path $baseFolder 'ProjectOne/custom/perProjectFile.algo' $filesToInclude.destinationFullPath | Should -Contain $rootDestination $filesToInclude.destinationFullPath | Should -Contain $projectDestination $filesToExclude | Should -BeNullOrEmpty + $filesToRemove | Should -BeNullOrEmpty } finally { if (Test-Path $perProjectFile) { @@ -2182,13 +2446,13 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() + filesToRemove = @() } } - $baseFolder = 'baseFolder' $projects = @('ProjectA') - $filesWithoutOriginal, $excludesWithoutOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -projects $projects + $filesWithoutOriginal, $excludesWithoutOriginal, $removesWithoutOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -projects $projects $filesWithoutOriginal | Should -Not -BeNullOrEmpty @@ -2202,7 +2466,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { $filesWithoutOriginal.destinationFullPath | Should -Not -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateRepoSettingsFileName)) $filesWithoutOriginal.destinationFullPath | Should -Not -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateProjectSettingsFileName)) - $filesWithOriginal, $excludesWithOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder -projects $projects + $filesWithOriginal, $excludesWithOriginal, $removesWithOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder -projects $projects $filesWithOriginal | Should -Not -BeNullOrEmpty @@ -2212,7 +2476,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { $filesWithOriginal.destinationFullPath | Should -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateProjectSettingsFileName)) $excludesWithoutOriginal | Should -BeNullOrEmpty + $removesWithoutOriginal | Should -BeNullOrEmpty + $excludesWithOriginal | Should -BeNullOrEmpty + $removesWithOriginal | Should -BeNullOrEmpty } finally { if (Test-Path $customTemplateFolder) { @@ -2231,10 +2498,11 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt" }) filesToExclude = @(@{ filter = "test.txt" }) + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # test.txt should not be in filesToInclude $includedTestTxt = $filesToInclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") } @@ -2252,10 +2520,11 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt" }) filesToExclude = @(@{ filter = "nonexistent.xyz" }) + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # All txt files should be included $filesToInclude | Should -Not -BeNullOrEmpty @@ -2267,6 +2536,29 @@ Describe "GetFilesToUpdate (general files to update logic)" { $excludedNonExistent | Should -BeNullOrEmpty } + It 'GetFilesToUpdate excludes files with different destinations that match both include and exclude patterns' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }, @{ filter = "test.txt"; destinationName = "test.renamed.txt" }) + filesToExclude = @(@{ filter = "test.txt" }) + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + # test.txt should not be in filesToInclude + $filesToInclude | Should -BeNullOrEmpty + + # test.txt should be in filesToExclude two times with different destinations + $testTxtFiles = $filesToExclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") } + $testTxtFiles.Count | Should -Be 2 + $testTxtFiles[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.renamed.txt') + $testTxtFiles[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.txt') + } + It 'GetFilesToUpdate handles overlapping include patterns with different destinations' { $settings = @{ type = "NotPTE" @@ -2277,16 +2569,435 @@ Describe "GetFilesToUpdate (general files to update logic)" { @{ filter = "test.txt"; destinationFolder = "folder2" } ) filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # Should have two entries for test.txt with different destinations $testTxtFiles = $filesToInclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") } $testTxtFiles.Count | Should -Be 2 - $testTxtFiles[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'folder1/test.txt') - $testTxtFiles[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'folder2/test.txt') + $testTxtFiles[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'folder1/test.txt') + $testTxtFiles[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'folder2/test.txt') + } + + It 'GetFilesToUpdate filesToRemove resolves files from base folder' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + filesToRemove = @(@{ filter = "test.txt" }) + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + # filesToRemove should one entry resolved from the base folder + $filesToRemove | Should -Not -BeNullOrEmpty + $filesToRemove.Count | Should -Be 1 + $filesToRemove[0].sourceFullPath | Should -Be (Join-Path $baseFolder "test.txt") + $filesToRemove[0].destinationFullPath | Should -Be (Join-Path $baseFolder "test.txt") + $filesToRemove[0].originalSourceFullPath | Should -Be $null + + # filesToInclude should be empty + $filesToInclude | Should -BeNullOrEmpty + } + + It 'GetFilesToUpdate filesToRemove resolves missing template files from base folder' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + filesToRemove = @(@{ filter = "test.base.txt" }) + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + # filesToRemove should have one entry pointing to the base folder file + $filesToRemove | Should -Not -BeNullOrEmpty + $filesToRemove.Count | Should -Be 1 + $filesToRemove[0].sourceFullPath | Should -Be $testBaseTxtFile + $filesToRemove[0].destinationFullPath | Should -Be $testBaseTxtFile + $filesToRemove[0].originalSourceFullPath | Should -Be $null + } + + It 'GetFilesToUpdate filesToRemove resolves template files not in base folder' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test2.txt" }) + filesToExclude = @() + filesToRemove = @(@{ filter = "test2.txt" }) + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + # filesToRemove should have one entry resolved from the template folder + $filesToRemove | Should -Not -BeNullOrEmpty + $filesToRemove.Count | Should -Be 1 + $filesToRemove[0].sourceFullPath | Should -Be (Join-Path $templateFolder "test2.txt") + $filesToRemove[0].destinationFullPath | Should -Be (Join-Path $baseFolder "test2.txt") + $filesToRemove[0].originalSourceFullPath | Should -Be $null + + # filesToExclude should be empty + $filesToExclude | Should -BeNullOrEmpty + } + + It 'GetFilesToUpdate filesToRemove excludes matching files from filesToInclude' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }, @{ filter = "test2.txt" }) + filesToExclude = @() + filesToRemove = @(@{ filter = "test.txt" }, @{ filter = "test2.txt" }) + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + # filesToInclude should be empty + $filesToInclude | Should -BeNullOrEmpty + } + + It 'GetFilesToUpdate filesToRemove excludes matching files from filesToExclude' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }) + filesToExclude = @(@{ filter = "test.txt" }) + filesToRemove = @(@{ filter = "test.txt" }) + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + # filesToExclude should be empty + $filesToExclude | Should -BeNullOrEmpty + } + + It 'GetFilesToUpdate filesToRemove keeps not matching files in filesToInclude' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }) + filesToExclude = @() + filesToRemove = @(@{ filter = "test.base.txt" }) + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + # test.txt should still be in filesToInclude + $filesToInclude.SourceFullPath | Should -Contain ( Join-Path $templateFolder "test.txt" ) + $filesToInclude.destinationFullPath | Should -Contain ( Join-Path $baseFolder "test.txt" ) + } + + It 'GetFilesToUpdate filesToRemove keeps not matching files in filesToExclude' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }) + filesToExclude = @(@{ filter = "test.txt" }) + filesToRemove = @(@{ filter = "test.base.txt" }) + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + # test.txt should still be in filesToExclude + $filesToExclude.SourceFullPath | Should -Contain ( Join-Path $templateFolder "test.txt" ) + $filesToExclude.destinationFullPath | Should -Contain ( Join-Path $baseFolder "test.txt" ) + } + + It 'GetFilesToUpdate filesToRemove uses destinationFolder and destinationName keys' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + filesToRemove = @(@{ filter = "test.txt"; destinationFolder = "subfolder"; destinationName = "testsub.txt" }) + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToRemove | Should -Not -BeNullOrEmpty + $filesToRemove.Count | Should -Be 1 + $filesToRemove[0].sourceFullPath | Should -Be (Join-Path $baseFolder "subfolder/testsub.txt") + $filesToRemove[0].destinationFullPath | Should -Be (Join-Path $baseFolder "subfolder/testsub.txt") + $filesToRemove[0].originalSourceFullPath | Should -Be $null + } + + It 'GetFilesToUpdate filesToRemove with perProject expands per project' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + filesToRemove = @(@{ filter = "testsub.txt"; destinationFolder = "subfolder"; perProject = $true }) + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -projects @('.', 'project1', 'project2') + + # filesToRemove should have 3 entries: one for root ('.'), one for project1, and one for project2, all pointing to the respective subfolder/testsub.txt + $filesToRemove | Should -Not -BeNullOrEmpty + $filesToRemove.Count | Should -Be 3 + $filesToRemove.destinationFullPath | Should -Contain (Join-Path $baseFolder "subfolder/testsub.txt") + $filesToRemove.destinationFullPath | Should -Contain (Join-Path $baseFolder "project1/subfolder/testsub.txt") + $filesToRemove.destinationFullPath | Should -Contain (Join-Path $baseFolder "project2/subfolder/testsub.txt") + } + + It 'GetFilesToUpdate empty filesToRemove returns empty third element' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToRemove | Should -BeNullOrEmpty + } + + It 'GetFilesToUpdate unknown filesToRemove returns empty third element' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + filesToRemove = @(@{ filter = "*.unknown" }) + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToRemove | Should -BeNullOrEmpty + } + + It 'GetFilesToUpdate template settings filesToInclude added to filesToInclude' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }) + filesToExclude = @() + filesToRemove = @() + } + } + + $templateSettings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.ps1" }) + filesToExclude = @() + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings + + # Both test.txt and test.ps1 should be in filesToInclude + $filesToInclude.sourceFullPath | Should -Contain $testTxtFile + $filesToInclude.sourceFullPath | Should -Contain $testPSFile + } + + It 'GetFilesToUpdate template settings filesToExclude added to filesToExclude' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }, @{ filter = "test.ps1" }) + filesToExclude = @(@{ filter = "test.txt" }) + filesToRemove = @() + } + } + + $templateSettings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @(@{ filter = "test.ps1" }) + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings + + # Both test.txt and test.ps1 should be in filesToExclude + $filesToExclude.sourceFullPath | Should -Contain $testTxtFile + $filesToExclude.sourceFullPath | Should -Contain $testPSFile + } + + It 'GetFilesToUpdate template settings filesToRemove added to filesToRemove' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + filesToRemove = @(@{ filter = "test.base.txt" }) + } + } + + $templateSettings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + filesToRemove = @(@{ filter = "test.base.ps1" }) + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings + + # Both test.base.txt and test.base.ps1 should be in filesToRemove + $filesToRemove.destinationFullPath | Should -Contain $testBaseTxtFile + $filesToRemove.destinationFullPath | Should -Contain $testBasePSFile + } + + It 'GetFilesToUpdate template settings unusedALGoSystemFiles added to unusedALGoSystemFiles' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @("test.txt") + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }) + filesToExclude = @() + filesToRemove = @() + } + } + + $templateSettings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @("test.ps1") + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.ps1" }) + filesToExclude = @() + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings + + # Both test.txt and test.ps1 should be in filesToExclude + $filesToExclude.sourceFullPath | Should -Contain $testTxtFile + $filesToExclude.sourceFullPath | Should -Contain $testPSFile + } + + It 'GetFilesToUpdate filesToInclude includes original template files missing in template' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.original.txt" }) + filesToExclude = @() + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder + + # test.original.txt of original template should be in filesToInclude + $testOriginalTemplateTxtFiles = @($filesToInclude | Where-Object { $_.sourceFullPath -eq $testOriginalTemplateTxtFile }) + $testOriginalTemplateTxtFiles | Should -Not -BeNullOrEmpty + $testOriginalTemplateTxtFiles.Count | Should -Be 1 + $testOriginalTemplateTxtFiles[0].sourceFullPath | Should -Be $testOriginalTemplateTxtFile + $testOriginalTemplateTxtFiles[0].originalSourceFullPath | Should -Be $null + $testOriginalTemplateTxtFiles[0].destinationFullPath | Should -Be ( Join-Path $baseFolder "test.original.txt" ) + } + + It 'GetFilesToUpdate filesToExclude excludes original template files missing in template' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.original.txt" }) + filesToExclude = @(@{ filter = "test.original.txt" }) + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder + + # test.original.txt of original template should be in filesToExclude + $testOriginalTemplateTxtFiles = @($filesToExclude | Where-Object { $_.sourceFullPath -eq $testOriginalTemplateTxtFile }) + $testOriginalTemplateTxtFiles | Should -Not -BeNullOrEmpty + $testOriginalTemplateTxtFiles.Count | Should -Be 1 + $testOriginalTemplateTxtFiles[0].sourceFullPath | Should -Be $testOriginalTemplateTxtFile + $testOriginalTemplateTxtFiles[0].originalSourceFullPath | Should -Be $null + $testOriginalTemplateTxtFiles[0].destinationFullPath | Should -Be ( Join-Path $baseFolder "test.original.txt" ) + } + + It 'GetFilesToUpdate filesToInclude not including original template files existing in template' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }) + filesToExclude = @() + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder + + # test.txt of template should be in filesToInclude + $testTxtFiles = @($filesToInclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") }) + $testTxtFiles | Should -Not -BeNullOrEmpty + $testTxtFiles.Count | Should -Be 1 + $testTxtFiles[0].sourceFullPath | Should -Be (Join-Path $templateFolder "test.txt") + $testTxtFiles[0].originalSourceFullPath | Should -Be ( Join-Path $originalTemplateFolder "test.txt" ) + $testTxtFiles[0].destinationFullPath | Should -Be (Join-Path $baseFolder "test.txt") + + # test.txt of original template should not be in filesToInclude + $filesToInclude.SourceFullPath | Should -Not -Contain ( Join-Path $originalTemplateFolder "test.txt" ) + } + + It 'GetFilesToUpdate filesToExclude not excluding original template files existing in template' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }) + filesToExclude = @(@{ filter = "test.txt" }) + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder + + # test.txt of template should be in filesToExclude + $testTxtFiles = @($filesToExclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") }) + $testTxtFiles | Should -Not -BeNullOrEmpty + $testTxtFiles.Count | Should -Be 1 + $testTxtFiles[0].sourceFullPath | Should -Be (Join-Path $templateFolder "test.txt") + $testTxtFiles[0].originalSourceFullPath | Should -Be ( Join-Path $originalTemplateFolder "test.txt" ) + $testTxtFiles[0].destinationFullPath | Should -Be (Join-Path $baseFolder "test.txt") + + # test.txt of original template should not be in filesToExclude + $filesToExclude.SourceFullPath | Should -Not -Contain ( Join-Path $originalTemplateFolder "test.txt" ) } } @@ -2301,6 +3012,13 @@ Describe "GetFilesToUpdate (real template)" { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'realAppSourceAppTemplateFolder', Justification = 'False positive.')] $realAppSourceAppTemplateFolder = Join-Path $PSScriptRoot "../Templates/AppSource App" -Resolve + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'powerPlatformFiles', Justification = 'False positive.')] + $powerPlatformFiles = @( + ".github/workflows/_BuildPowerPlatformSolution.yaml", + ".github/workflows/PullPowerPlatformChanges.yaml", + ".github/workflows/PushPowerPlatformChanges.yaml" + ) } It 'Return the correct files to exclude when type is PTE and powerPlatformSolutionFolder is not empty' { @@ -2311,19 +3029,21 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 25 - $filesToInclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/_BuildPowerPlatformSolution.yaml") - $filesToInclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/PullPowerPlatformChanges.yaml") - $filesToInclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/PushPowerPlatformChanges.yaml") + $filesToInclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $powerPlatformFiles[0]) + $filesToInclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $powerPlatformFiles[1]) + $filesToInclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $powerPlatformFiles[2]) - # No files to remove + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty + $filesToRemove | Should -BeNullOrEmpty } It 'Return PP files in filesToExclude when type is PTE but powerPlatformSolutionFolder is empty' { @@ -2334,33 +3054,32 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 22 $filesToInclude | ForEach-Object { - $_.sourceFullPath | Should -Not -Be (Join-Path $realPTETemplateFolder ".github/workflows/_BuildPowerPlatformSolution.yaml") - $_.sourceFullPath | Should -Not -Be (Join-Path $realPTETemplateFolder ".github/workflows/PullPowerPlatformChanges.yaml") - $_.sourceFullPath | Should -Not -Be (Join-Path $realPTETemplateFolder ".github/workflows/PushPowerPlatformChanges.yaml") + $fileToInclude = $_ + $powerPlatformFiles | ForEach-Object { + $fileToInclude.sourceFullPath | Should -Not -Be (Join-Path $realPTETemplateFolder $_) + } } # All PP files to remove $filesToExclude | Should -Not -BeNullOrEmpty - $filesToExclude.Count | Should -Be 3 - - $filesToExclude[0].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder ".github/workflows/_BuildPowerPlatformSolution.yaml") - $filesToExclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' ".github/workflows/_BuildPowerPlatformSolution.yaml") - - $filesToExclude[1].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder ".github/workflows/PushPowerPlatformChanges.yaml") - $filesToExclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' ".github/workflows/PushPowerPlatformChanges.yaml") + $filesToExclude.Count | Should -Be $powerPlatformFiles.Count - $filesToExclude[2].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder ".github/workflows/PullPowerPlatformChanges.yaml") - $filesToExclude[2].destinationFullPath | Should -Be (Join-Path 'baseFolder' ".github/workflows/PullPowerPlatformChanges.yaml") + for ($i = 0; $i -lt $powerPlatformFiles.Count; $i++) { + $filesToExclude[$i].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder $powerPlatformFiles[$i]) + $filesToExclude[$i].destinationFullPath | Should -Be (Join-Path 'baseFolder' $powerPlatformFiles[$i]) + } + $filesToRemove | Should -BeNullOrEmpty } It 'Return the correct files when unusedALGoSystemFiles is specified' { @@ -2371,10 +3090,11 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 24 @@ -2384,6 +3104,7 @@ Describe "GetFilesToUpdate (real template)" { $filesToExclude.Count | Should -Be 1 $filesToExclude[0].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder ".github/Test Next Major.settings.json") $filesToExclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' '.github/Test Next Major.settings.json') + $filesToRemove | Should -BeNullOrEmpty } It 'Return the correct files when unusedALGoSystemFiles is specified and no PP solution is present' { @@ -2394,10 +3115,11 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 21 @@ -2407,9 +3129,10 @@ Describe "GetFilesToUpdate (real template)" { $filesToExclude.Count | Should -Be 4 $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/Test Next Major.settings.json") - $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/_BuildPowerPlatformSolution.yaml") - $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/PullPowerPlatformChanges.yaml") - $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/PushPowerPlatformChanges.yaml") + $powerPlatformFiles | ForEach-Object { + $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $_) + } + $filesToRemove | Should -BeNullOrEmpty } It 'Returns the custom template settings files when there is a custom template' { @@ -2420,12 +3143,13 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() + filesToRemove = @() } } $customTemplateFolder = $realPTETemplateFolder $originalTemplateFolder = $realAppSourceAppTemplateFolder - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template $filesToInclude | Should -Not -BeNullOrEmpty @@ -2457,8 +3181,70 @@ Describe "GetFilesToUpdate (real template)" { $projectSettingsFilesFromCustomTemplate[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' '.github/AL-Go-TemplateProjectSettings.doNotEdit.json') $projectSettingsFilesFromCustomTemplate[1].type | Should -Be '' - # No files to exclude + # No files to exclude or remove + $filesToExclude | Should -BeNullOrEmpty + $filesToRemove | Should -BeNullOrEmpty + } + + It 'Returns the original template PP files in filesToInclude when there is a custom template without them and powerPlatformSolutionFolder is not empty' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = "PowerPlatformSolution" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + filesToRemove = @() + } + } + + # AppSource App is used as custom template because it has no PP workflows, simulating a custom PTE fork that stripped them out + $customTemplateFolder = $realAppSourceAppTemplateFolder + $originalTemplateFolder = $realPTETemplateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template + + $filesToInclude | Should -Not -BeNullOrEmpty + $powerPlatformFiles | ForEach-Object { + $filesToInclude.sourceFullPath | Should -Not -Contain (Join-Path $customTemplateFolder $_) + $filesToInclude.sourceFullPath | Should -Contain (Join-Path $originalTemplateFolder $_) + } + + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty + $filesToRemove | Should -BeNullOrEmpty + } + + It 'Returns the original template PP files in filesToExclude when there is a custom template without them and powerPlatformSolutionFolder is empty' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = "" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + filesToRemove = @() + } + } + + # AppSource App is used as custom template because it has no PP workflows, simulating a custom PTE fork that stripped them out + $customTemplateFolder = $realAppSourceAppTemplateFolder + $originalTemplateFolder = $realPTETemplateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template + + $filesToInclude | Should -Not -BeNullOrEmpty + $powerPlatformFiles | ForEach-Object { + $filesToInclude.sourceFullPath | Should -Not -Contain (Join-Path $customTemplateFolder $_) + $filesToInclude.sourceFullPath | Should -Not -Contain (Join-Path $originalTemplateFolder $_) + } + + $filesToExclude | Should -Not -BeNullOrEmpty + $powerPlatformFiles | ForEach-Object { + $filesToExclude.sourceFullPath | Should -Not -Contain (Join-Path $customTemplateFolder $_) + $filesToExclude.sourceFullPath | Should -Contain (Join-Path $originalTemplateFolder $_) + } + + # No files to exclude + $filesToRemove | Should -BeNullOrEmpty } It 'GetFilesToUpdate handles AppSource template type correctly' { @@ -2469,10 +3255,11 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realAppSourceAppTemplateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realAppSourceAppTemplateFolder # PowerPlatform files should be excluded for AppSource App too (same as PTE) $filesToInclude | Should -Not -BeNullOrEmpty @@ -2493,14 +3280,16 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder # No additional files should be excluded due to unusedALGoSystemFiles $ppExcludes = $filesToExclude | Where-Object { $_.sourceFullPath -like "*_BuildPowerPlatformSolution.yaml" -or $_.sourceFullPath -like "*PullPowerPlatformChanges.yaml" -or $_.sourceFullPath -like "*PushPowerPlatformChanges.yaml" } $ppExcludes.Count | Should -Be 3 # Only PP files should be excluded by default + $filesToRemove | Should -BeNullOrEmpty } It 'GetFilesToUpdate marks settings files with correct type' { @@ -2511,10 +3300,11 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -projects @('Project1') + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -projects @('Project1') # Check that settings files have type = 'settings' $repoSettingsFiles = @($filesToInclude | Where-Object { $_.sourceFullPath -like "*$RepoSettingsFileName" -and $_.destinationFullPath -like "*.github*$RepoSettingsFileName" }) @@ -2524,6 +3314,7 @@ Describe "GetFilesToUpdate (real template)" { $projectSettingsFiles = @($filesToInclude | Where-Object { $_.sourceFullPath -like "*$ALGoSettingsFileName" -and $_.destinationFullPath -like "*Project1*.AL-Go*" }) $projectSettingsFiles | Should -Not -BeNullOrEmpty $projectSettingsFiles[0].type | Should -Be 'settings' + $filesToRemove | Should -BeNullOrEmpty } It 'GetFilesToUpdate handles multiple projects correctly' { @@ -2534,11 +3325,12 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() + filesToRemove = @() } } $projects = @('ProjectA', 'ProjectB', 'ProjectC') - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -projects $projects + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -projects $projects # Each project should have its own settings file $projectASettings = $filesToInclude | Where-Object { $_.destinationFullPath -like "*ProjectA*.AL-Go*" } @@ -2558,10 +3350,11 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @(@{ filter = "Test Next Major.settings.json" }) + filesToRemove = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder # Test Next Major.settings.json should be excluded $testNextMajor = $filesToInclude | Where-Object { $_.sourceFullPath -like "*Test Next Major.settings.json" } diff --git a/e2eTests/scenarios/CustomTemplate/runtest.ps1 b/e2eTests/scenarios/CustomTemplate/runtest.ps1 index 5cdef971b3..b8745d1149 100644 --- a/e2eTests/scenarios/CustomTemplate/runtest.ps1 +++ b/e2eTests/scenarios/CustomTemplate/runtest.ps1 @@ -31,8 +31,12 @@ Write-Host -ForegroundColor Yellow @' # - Create a new repository based on the PTE template with 1 app, using compilerfolder and donotpublishapps (this will be the "final" template repository) # - Run Update AL-Go System Files in final repo (using custom template repository as template) # - Run Update AL-Go System files in custom template repository +# - Validate that custom AL-Go files are applied in custom template repository # - Validate that custom job is present in custom template repository # - Run Update AL-Go System files in final repo +# - Validate that custom AL-Go files of template repository are applied in final repository +# - Run Update AL-Go System files in final repo +# - Validate that custom AL-Go files of template repository and final repository are applied in final repository # - Validate that custom job is present in final repo # '@ @@ -55,6 +59,8 @@ $template = "https://github.com/$pteTemplate" # Login SetTokenAndRepository -github:$github -githubOwner $githubOwner -appId $e2eAppId -appKey $e2eAppKey -repository $repository +#region create repositories + # Create template repository CreateAlGoRepository ` -github:$github ` @@ -64,6 +70,9 @@ CreateAlGoRepository ` -branch $branch $templateRepoPath = (Get-Location).Path +# Stop all currently running workflows on template repository +CancelAllWorkflows -repository $templateRepository + Set-Location $prevLocation $appName = 'MyApp' @@ -76,15 +85,26 @@ CreateAlGoRepository ` -template $template ` -repository $repository ` -branch $branch ` + -addRepoSettings @{ "useCompilerFolder" = $true; "doNotPublishApps" = $true } ` -contentScript { Param([string] $path) $null = CreateNewAppInFolder -folder $path -name $appName -publisher $publisherName } $finalRepoPath = (Get-Location).Path +# Stop all currently running workflows on final repository +CancelAllWorkflows -repository $repository + # Update AL-Go System Files to use template repository RunUpdateAlGoSystemFiles -directCommit -wait -templateUrl $templateRepository -ghTokenWorkflow $algoauthapp -repository $repository -branch $branch | Out-Null +# Stop all currently running workflows on final repository +CancelAllWorkflows -repository $repository + +#endregion + +#region setup template repository customizations + Set-Location $templateRepoPath Pull @@ -154,6 +174,10 @@ on: branches: - main +defaults: + run: + shell: powershell + jobs: CustomJob: runs-on: [ windows-latest ] @@ -166,23 +190,101 @@ jobs: "@ Set-Content -Path $customWorkflowFile -Value $customWorkflowContent +$finalRepoCustomWorkflowContent = $customWorkflowContent if($linux) { - # Modify workflow to run on ubuntu-latest if the test is running on linux. AL-Go will not modify workflow files based on platform, so we need to do it here to ensure the test works correctly. - $customWorkflowContent = $customWorkflowContent -replace 'windows-latest', 'ubuntu-latest' + $finalRepoCustomWorkflowContent = $finalRepoCustomWorkflowContent -replace 'windows-latest', 'ubuntu-latest' + $finalRepoCustomWorkflowContent = $finalRepoCustomWorkflowContent -replace 'shell: powershell', 'shell: pwsh' } -# Add another custom file in the template repository (to be ignored unless specifically added via the settings) -$customFileName = 'CustomTemplateFile.txt' -$customFile = Join-Path $templateRepoPath $customFileName -$customFileContent = "This is a custom file in the template repository." -Set-Content -Path $customFile -Value $customFileContent +# Add custom files in the template repository +$defaultCustomFileName = 'CustomTemplateFile.Default.txt' +$defaultCustomFile = Join-Path $templateRepoPath $defaultCustomFileName +$defaultCustomFileContent = "This is a default custom file in the template repository." +Set-Content -Path $defaultCustomFile -Value $defaultCustomFileContent + +$optionalCustomFileName = 'CustomTemplateFile.Optional.txt' +$optionalCustomFile = Join-Path $templateRepoPath $optionalCustomFileName +$optionalCustomFileContent = "This is an optional custom file in the template repository." +Set-Content -Path $optionalCustomFile -Value $optionalCustomFileContent + +$legacyCustomFileName = 'CustomTemplateFile.Legacy.txt' + +# Remove workflow files from template repository +$excludedWorkflowFileName = 'DeployReferenceDocumentation.yaml' +$excludedWorkflowFileRelativePath = Join-Path '.github/workflows' $excludedWorkflowFileName +$excludedWorkflowFile = Join-Path $templateRepoPath $excludedWorkflowFileRelativePath +Remove-Item -Path $excludedWorkflowFile -Force | Out-Null + +$missingWorkflowFileName = 'Troubleshooting.yaml' +$missingWorkflowFileRelativePath = Join-Path '.github/workflows' $missingWorkflowFileName +$missingWorkflowFile = Join-Path $templateRepoPath $missingWorkflowFileRelativePath +Remove-Item -Path $missingWorkflowFile -Force | Out-Null + +# Add customALGoFiles settings to the template repository +$null = Add-PropertiesToJsonFile -path '.github/AL-Go-Settings.json' -properties @{ + "customALGoFiles" = @{ + "filesToInclude" = @( @{ "filter" = $defaultCustomFileName } ) + "filesToExclude" = @( @{ "sourceFolder" = ".github/workflows"; "filter" = $excludedWorkflowFileName } ) + "filesToRemove" = @( @{ "filter" = $legacyCustomFileName } ) + } +} # Push -CommitAndPush -commitMessage 'Add template customizations' +CommitAndPush -commitMessage 'Add template customizations [skip ci]' + +#endregion -# Do not run workflows on template repository +#region update template repository with template repository customizations + +# Update AL-Go System Files for template repository to update customizations from template repository +RunUpdateAlGoSystemFiles -directCommit -wait -templateUrl $template -ghTokenWorkflow $algoauthapp -repository $templateRepository -branch $branch | Out-Null + +# Stop all currently running workflows on template repository CancelAllWorkflows -repository $templateRepository +# Pull changes +Pull + +# Check that custom workflow file is present +(Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $customWorkflowContent.Replace("`r", "").TrimEnd("`n") + +# Check that default custom file is present +(Join-Path (Get-Location) $defaultCustomFileName) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $defaultCustomFileName) | Should -Be $defaultCustomFileContent.Replace("`r", "").TrimEnd("`n") +# Check that optional custom file is present +(Join-Path (Get-Location) $optionalCustomFileName) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $optionalCustomFileName) | Should -Be $optionalCustomFileContent.Replace("`r", "").TrimEnd("`n") +# Check that legacy custom file is NOT present +(Join-Path (Get-Location) $legacyCustomFileName) | Should -Not -Exist + +# Check that excluded workflow file is NOT present (in template's filesToExclude) +(Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Not -Exist +# Check that missing workflow file is present (in default filesToExclude) +(Join-Path (Get-Location) $missingWorkflowFileRelativePath) | Should -Exist + +# Remove missing workflow files from template repository again +Remove-Item -Path $missingWorkflowFile -Force | Out-Null + +# Push +CommitAndPush -commitMessage 'Restore template customizations [skip ci]' + +#endregion + +#region validate template repository CI/CD workflow + +# Run CICD +$run = RunCICD -repository $templateRepository -branch $branch -wait + +# Check Custom Jobs +Test-LogContainsFromRun -repository $templateRepository -runid $run.id -jobName 'CustomJob-TemplateInit' -stepName 'Init' -expectedText 'CustomJob-TemplateInit was here!' +Test-LogContainsFromRun -repository $templateRepository -runid $run.id -jobName 'CustomJob-TemplateDeploy' -stepName 'Deploy' -expectedText 'CustomJob-TemplateDeploy was here!' +{ Test-LogContainsFromRun -repository $templateRepository -runid $run.id -jobName 'JustSomeTemplateJob' -stepName 'JustSomeTemplateStep' -expectedText 'JustSomeTemplateJob was here!' } | Should -Throw + +#endregion + +#region setup final repository customizations + # Add local customizations to the final repository Set-Location $finalRepoPath Pull @@ -245,14 +347,39 @@ $cicdYaml.AddCustomJobsToYaml($customJobs, [CustomizationOrigin]::FinalRepositor # save $cicdYaml.Save($cicdWorkflow) +# Add custom files in the final repository +$legacyCustomFileContent = "This is a removed custom file that will be removed in the final repository." +Set-Content -Path (Join-Path (Get-Location) $legacyCustomFileName) -Value $legacyCustomFileContent + +# Remove workflow files from final repository +Remove-Item -Path (Join-Path (Get-Location) $missingWorkflowFileRelativePath) -Force | Out-Null + +# Check that custom workflow file is NOT present +(Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Not -Exist + +# Check that default custom file is NOT present in final repository +(Join-Path (Get-Location) $defaultCustomFileName) | Should -Not -Exist +# Check that optional custom file is NOT present in final repository +(Join-Path (Get-Location) $optionalCustomFileName) | Should -Not -Exist +# Check that legacy workflow file is present in final repository +(Join-Path (Get-Location) $legacyCustomFileName) | Should -Exist + +# Check that excluded workflow file is present in final repository +(Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Exist +# Check that missing workflow file is NOT present in final repository +(Join-Path (Get-Location) $missingWorkflowFileRelativePath) | Should -Not -Exist # Push -CommitAndPush -commitMessage 'Add final repo customizations' +CommitAndPush -commitMessage 'Add final repo customizations [skip ci]' + +#endregion + +#region update final repository with template repository customizations -# Update AL-Go System Files to uptake UseProjectDependencies setting +# Update AL-Go System Files for the final repository to uptake customizations from template repository RunUpdateAlGoSystemFiles -directCommit -wait -templateUrl $templateRepository -ghTokenWorkflow $algoauthapp -repository $repository -branch $branch | Out-Null -# Stop all currently running workflows and run a new CI/CD workflow +# Stop all currently running workflows on final repository CancelAllWorkflows -repository $repository # Pull changes @@ -263,39 +390,82 @@ Pull # Check that custom workflow file is present (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist -Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $customWorkflowContent.Replace("`r", "").TrimEnd("`n") +Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $finalRepoCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") + +# Check that default custom file is present (in template's filesToInclude) +(Join-Path (Get-Location) $defaultCustomFileName) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $defaultCustomFileName) | Should -Be $defaultCustomFileContent.Replace("`r", "").TrimEnd("`n") +# Check that optional custom file is NOT present (not in default or template's filesToInclude) +(Join-Path (Get-Location) $optionalCustomFileName) | Should -Not -Exist +# Check that legacy custom file is NOT present (in template's filesToRemove) +(Join-Path (Get-Location) $legacyCustomFileName) | Should -Not -Exist + +# Check that excluded workflow file is NOT present (in default filesToInclude and template's filesToExclude) +(Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Not -Exist +# Check that missing workflow file is present (in default filesToInclude, propagated from PTE template) +(Join-Path (Get-Location) $missingWorkflowFileRelativePath) | Should -Exist + +# Add customALGoFiles settings to the final reppository +$null = Add-PropertiesToJsonFile -path '.github/AL-Go-Settings.json' -properties @{ + "customALGoFiles" = @{ + "filesToInclude" = @( @{ "filter" = $optionalCustomFileName } ) + "filesToExclude" = @( @{ "filter" = $defaultCustomFileName } ) + } +} -# Check that custom file is NOT present -(Join-Path (Get-Location) $customFileName) | Should -Not -Exist # Custom file should not be copied by default +# Push +CommitAndPush -commitMessage 'Add custom files to be updated when updating AL-Go system files [skip ci]' -# Add custom file to be copied via settings -$null = Add-PropertiesToJsonFile -path '.github/AL-Go-Settings.json' -properties @{ "customALGoFiles" = @{ "filesToInclude" = @( @{ "filter" = $customFileName } ) } } +#endregion -# Push -CommitAndPush -commitMessage 'Add custom file to be updated when updating AL-Go system files [skip ci]' +#region update final repository with template and final repository customizations -# Update AL-Go System Files to uptake custom file +# Update AL-Go System Files for final repository to uptake customizations from final repository RunUpdateAlGoSystemFiles -directCommit -wait -templateUrl $templateRepository -ghTokenWorkflow $algoauthapp -repository $repository -branch $branch | Out-Null +# Stop all currently running workflows on final repository +CancelAllWorkflows -repository $repository + # Pull changes Pull -# Check that custom file is now present -(Join-Path (Get-Location) $customFileName) | Should -Exist -Get-ContentLF -Path (Join-Path (Get-Location) $customFileName)| Should -Be $customFileContent.Replace("`r", "").TrimEnd("`n") +# Check that custom workflow file is present +(Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $finalRepoCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") + +# Check that default custom file is NOT present (in repos's filesToExclude and template's filesToInclude) +(Join-Path (Get-Location) $defaultCustomFileName) | Should -Not -Exist +# Check that optional custom file is present (in repos's filesToInclude) +(Join-Path (Get-Location) $optionalCustomFileName) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $optionalCustomFileName) | Should -Be $optionalCustomFileContent.Replace("`r", "").TrimEnd("`n") +# Check that legacy custom file is NOT present (in template's filesToRemove) +(Join-Path (Get-Location) $legacyCustomFileName) | Should -Not -Exist + +# Check that excluded workflow file is NOT present (in default filesToInclude and template's filesToExclude) +(Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Not -Exist +# Check that missing workflow file is present (in default filesToInclude, propagated from PTE template) +(Join-Path (Get-Location) $missingWorkflowFileRelativePath) | Should -Exist + +#endregion + +#region validate final repository CI/CD workflow # Run CICD $run = RunCICD -repository $repository -branch $branch -wait # Check Custom Jobs -Test-LogContainsFromRun -runid $run.id -jobName 'CustomJob-TemplateInit' -stepName 'Init' -expectedText 'CustomJob-TemplateInit was here!' -Test-LogContainsFromRun -runid $run.id -jobName 'CustomJob-TemplateDeploy' -stepName 'Deploy' -expectedText 'CustomJob-TemplateDeploy was here!' -Test-LogContainsFromRun -runid $run.id -jobName 'CustomJob-PreDeploy' -stepName 'PreDeploy' -expectedText 'CustomJob-PreDeploy was here!' -Test-LogContainsFromRun -runid $run.id -jobName 'CustomJob-PostDeploy' -stepName 'PostDeploy' -expectedText 'CustomJob-PostDeploy was here!' -{ Test-LogContainsFromRun -runid $run.id -jobName 'JustSomeJob' -stepName 'JustSomeStep' -expectedText 'JustSomeJob was here!' } | Should -Throw -{ Test-LogContainsFromRun -runid $run.id -jobName 'JustSomeTemplateJob' -stepName 'JustSomeTemplateStep' -expectedText 'JustSomeTemplateJob was here!' } | Should -Throw +Test-LogContainsFromRun -repository $repository -runid $run.id -jobName 'CustomJob-TemplateInit' -stepName 'Init' -expectedText 'CustomJob-TemplateInit was here!' +Test-LogContainsFromRun -repository $repository -runid $run.id -jobName 'CustomJob-TemplateDeploy' -stepName 'Deploy' -expectedText 'CustomJob-TemplateDeploy was here!' +Test-LogContainsFromRun -repository $repository -runid $run.id -jobName 'CustomJob-PreDeploy' -stepName 'PreDeploy' -expectedText 'CustomJob-PreDeploy was here!' +Test-LogContainsFromRun -repository $repository -runid $run.id -jobName 'CustomJob-PostDeploy' -stepName 'PostDeploy' -expectedText 'CustomJob-PostDeploy was here!' +{ Test-LogContainsFromRun -repository $repository -runid $run.id -jobName 'JustSomeJob' -stepName 'JustSomeStep' -expectedText 'JustSomeJob was here!' } | Should -Throw +{ Test-LogContainsFromRun -repository $repository -runid $run.id -jobName 'JustSomeTemplateJob' -stepName 'JustSomeTemplateStep' -expectedText 'JustSomeTemplateJob was here!' } | Should -Throw + +#endregion Set-Location $prevLocation +RefreshToken -repository $repository RemoveRepository -repository $repository -path $finalRepoPath +RefreshToken -repository $templateRepository RemoveRepository -repository $templateRepository -path $templateRepoPath From 73f4ef336ea727179f2ba8aa2f63997ea3401774 Mon Sep 17 00:00:00 2001 From: OleWunschmann Date: Wed, 3 Jun 2026 17:35:08 +0200 Subject: [PATCH 02/34] fixed typo --- e2eTests/scenarios/CustomTemplate/runtest.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2eTests/scenarios/CustomTemplate/runtest.ps1 b/e2eTests/scenarios/CustomTemplate/runtest.ps1 index b8745d1149..b58278402e 100644 --- a/e2eTests/scenarios/CustomTemplate/runtest.ps1 +++ b/e2eTests/scenarios/CustomTemplate/runtest.ps1 @@ -405,7 +405,7 @@ Get-ContentLF -Path (Join-Path (Get-Location) $defaultCustomFileName) | Should - # Check that missing workflow file is present (in default filesToInclude, propagated from PTE template) (Join-Path (Get-Location) $missingWorkflowFileRelativePath) | Should -Exist -# Add customALGoFiles settings to the final reppository +# Add customALGoFiles settings to the final repository $null = Add-PropertiesToJsonFile -path '.github/AL-Go-Settings.json' -properties @{ "customALGoFiles" = @{ "filesToInclude" = @( @{ "filter" = $optionalCustomFileName } ) From bb38962d1f92dbcda677e7c36671a6a89f30db7f Mon Sep 17 00:00:00 2001 From: OleWunschmann Date: Wed, 3 Jun 2026 17:38:26 +0200 Subject: [PATCH 03/34] fixed function description --- Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 7dc531c1ac..a14ed7c513 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -1085,9 +1085,9 @@ function GetDefaultFilesToExclude { 1. filesToInclude: Files to copy from the template or original template to the destination. Built from default files to include and customALGoFiles.filesToInclude in settings and templateSettings, resolved against the template folder and original template folder (if any). 2. filesToExclude: Files to skip from copying; if they already exist in the destination they should be deleted. - Built from default files to exclude, customALGoFiles.filesToExclude, and customALGoFiles.filesToRemove in settings and templateSettings, resolved against the template folder and original template folder (if any). + Built from default files to exclude and customALGoFiles.filesToExclude in settings and templateSettings, resolved against the template folder and original template folder (if any). 3. filesToRemove: Files to unconditionally delete from the destination. - Built from customALGoFiles.filesToRemove in settings and templateSettings and resolved against the destination folder (not the template folder). + Built from customALGoFiles.filesToRemove in settings and templateSettings and resolved against template folder, the original template folder (if any), and the destination folder. The deprecated unusedALGoSystemFiles setting is also applied: matching files are moved from filesToInclude to filesToExclude with a deprecation warning. From 50ad11c3d6d42dd4ef8a416effe78ea94d633815 Mon Sep 17 00:00:00 2001 From: OleWunschmann Date: Tue, 9 Jun 2026 18:17:36 +0200 Subject: [PATCH 04/34] Fixed tests for ps5 and string-prefix check for folder traversal --- .../CheckForUpdates.HelperFunctions.ps1 | 8 +++--- Tests/CheckForUpdates.Action.Test.ps1 | 25 ++++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index a14ed7c513..9b5662ef6f 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -824,6 +824,8 @@ function ResolveFilePaths { return @() } + $sourceFolder = Join-Path $sourceFolder '' # Ensure source folder has a trailing slash for correct path resolution + $fullFilePaths = @() foreach($file in $files) { if($file.Keys -notcontains 'sourceFolder') { @@ -1170,7 +1172,7 @@ function GetFilesToUpdate { } $filesToInclude += @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) # Remove duplicates based on destinationFullPath, keeping the last one (setting > template settings > defaults; template folder > original template folder) - $filesToInclude = @($filesToInclude | Group-Object -Property destinationFullPath | ForEach-Object { $_.Group[-1] }) + $filesToInclude = @($filesToInclude | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[-1] }) # Determine files to exclude $filesToExcludeUnresolved = GetDefaultFilesToExclude -settings $settings @@ -1184,7 +1186,7 @@ function GetFilesToUpdate { } $filesToExclude += @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) # Remove duplicates based on destinationFullPath, keeping the last one (setting > template settings > defaults; template folder > original template folder) - $filesToExclude = @($filesToExclude | Group-Object -Property destinationFullPath | ForEach-Object { $_.Group[-1] }) + $filesToExclude = @($filesToExclude | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[-1] }) # Determine files to remove $filesToRemoveUnresolved = @() @@ -1199,7 +1201,7 @@ function GetFilesToUpdate { $filesToRemove += @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) $filesToRemove += @(ResolveFilePathsInDestinationFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) # Remove duplicates based on destinationFullPath, keeping the last one (settings > template settings; base folder > template folder > original template folder) - $filesToRemove = @($filesToRemove | Group-Object -Property destinationFullPath | ForEach-Object { $_.Group[-1] }) + $filesToRemove = @($filesToRemove | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[-1] }) # Exclude files from filesToInclude that are in filesToRemove (based on destination) $filesToInclude = @($filesToInclude | Where-Object { diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 1777c840af..aa61c5f164 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -1474,6 +1474,30 @@ Describe "ResolveFilePaths" { if (Test-Path $externalFolder) { Remove-Item -Path $externalFolder -Recurse -Force } } + It 'ResolveFilePaths skips files in folder whose name starts with source folder name' { + # Create a external file in folder whose name starts with the same prefix as sourceFolder + $externalFolder = "${sourceFolder}-external" + if (-not (Test-Path $externalFolder)) { New-Item -Path $externalFolder -ItemType Directory | Out-Null } + $externalFile = Join-Path $externalFolder "outside.txt" + Set-Content -Path $externalFile -Value "outside" + + $destinationFolder = "destinationFolder" + $destinationFolder = Join-Path $PSScriptRoot $destinationFolder + + $files = @( + @{ "sourceFolder" = "../sourceFolder-external"; "filter" = "*.txt" } + ) + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder + + # The file in the prefix-colliding folder must NOT be included + $fullFilePaths | ForEach-Object { $_.sourceFullPath | Should -Not -BeLike "${externalFolder}*" } + + # Cleanup + if (Test-Path $externalFile) { Remove-Item -Path $externalFile -Force } + if (Test-Path $externalFolder) { Remove-Item -Path $externalFolder -Recurse -Force } + } + It 'ResolveFilePaths returns empty when no files match filter' { $destinationFolder = "destinationFolder" $destinationFolder = Join-Path $rootFolder $destinationFolder @@ -2098,7 +2122,6 @@ Describe "GetFilesToUpdate (general files to update logic)" { # . # ├── test.ps1 # ├── test.txt - # ├── test2.txt # ├── test.base.ps1 # ├── test.base.txt # ├── subfolder From 7fa35229ce8dc50d4a6f958054e4582edf114f16 Mon Sep 17 00:00:00 2001 From: OleWunschmann Date: Tue, 14 Jul 2026 18:37:34 +0200 Subject: [PATCH 05/34] Extended settings schema and documentation for "destinationName" --- Actions/.Modules/settings.schema.json | 8 ++++++++ RELEASENOTES.md | 3 ++- Scenarios/CustomizingALGoForGitHub.md | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Actions/.Modules/settings.schema.json b/Actions/.Modules/settings.schema.json index 37554a8b7d..650d183d70 100644 --- a/Actions/.Modules/settings.schema.json +++ b/Actions/.Modules/settings.schema.json @@ -756,6 +756,10 @@ "type": "string", "description": "The destination folder where the files should be updated, relative to the repository root. If not specified, defaults to the same as the source file folder." }, + "destinationName": { + "type": "string", + "description": "The filename to use at the destination. If specified, overrides the source filename, allowing the file to be renamed when copied. Should be used together with a filter that matches a single file." + }, "perProject": { "type": "boolean", "description": "Indicates whether the file update should be applied per project. In that case, the destinationFolder is considered relative to each project folder." @@ -798,6 +802,10 @@ "type": "string", "description": "The destination folder where the files should be removed, relative to the repository root." }, + "destinationName": { + "type": "string", + "description": "The filename of the file at the destination. If specified, overrides the filter when looking up the file to remove. Should be used together with a filter that matches a single file." + }, "perProject": { "type": "boolean", "description": "Indicates whether the removal should be applied per project. In that case, destinationFolder is relative to each project folder." diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 8dafcd843e..02573f4a8b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -4,7 +4,8 @@ The `customALGoFiles` setting of a custom template was only applied on the next - **`filesToInclude`** now also resolves files from the original AL-Go template. Files present in the official template that are not overridden by your custom template are propagated to consumer repos. When a file exists in both, the custom template version takes precedence. - **`filesToExclude`** now also resolves files from the original AL-Go template (same dual-resolution as `filesToInclude`). Files resolved by `filesToInclude` whose source matches a `filesToExclude` entry are not copied to consumer repos, and existing copies are removed. -- **`filesToRemove`** (new property): Unconditionally removes matching files from consumer repos. Files are searched in both the template and end repository. Takes precedence over `filesToInclude`. Entries use `sourceFolder` (relative to the template), `filter`, and optionally `destinationFolder` and `perProject`. +- **`filesToRemove`** (new property): Unconditionally removes matching files from consumer repos. Files are searched in both the template and end repository. Takes precedence over `filesToInclude`. Entries use `sourceFolder` (relative to the template), `filter`, and optionally `destinationFolder`, `perProject`, and `destinationName`. +- **`destinationName`** (new property on `filesToInclude` and `filesToRemove`): Allows renaming a file at the destination. When set, the file is written to (or removed from) `/` instead of keeping the source filename. For `filesToRemove`, `destinationName` also overrides the `filter` when looking up the file to delete. Read more at [Customizing AL-Go for GitHub](Scenarios/CustomizingALGoForGitHub.md#Using-custom-template-files). diff --git a/Scenarios/CustomizingALGoForGitHub.md b/Scenarios/CustomizingALGoForGitHub.md index a3f8738178..c0e9227cea 100644 --- a/Scenarios/CustomizingALGoForGitHub.md +++ b/Scenarios/CustomizingALGoForGitHub.md @@ -239,6 +239,7 @@ In order to instruct AL-Go which files to look for at the template repository, y - `filter`: A string to use for filtering in the specified source path. It can contain `*` and `?` wildcards. _Example_: `*.ps1` or `fileToUpdate.ps1`. - `destinationFolder`: A path to a folder, relative to repository that is being updated, where the files should be placed. If not specified, defaults to the same as the source file folder. _Example_: `src/templateScripts`. - `perProject`: A boolean that indicates whether the matched files should be propagated for all available AL-Go projects. In that case, `destinationFolder` is relative to the project folder. _Example_: `.AL-Go/scripts`. +- `destinationName`: The filename to use at the destination. If specified, overrides the source filename, allowing the file to be renamed when copied. Should be used together with a `filter` that matches a single file. _Example_: `customScript.ps1`. > [!NOTE] > `filesToInclude` is used to define all the template files that will be used by AL-Go for GitHub. If a template file is not matched, it will be ignored. Please pay attention, when changing the file configurations: there might be template files that were previously propagated to your repositories. In case these files are no longer matched via `filesToInclude`, AL-Go for GitHub will ignore them and you might have to remove them manually. @@ -280,6 +281,7 @@ The following table summarizes how AL-Go for GitHub manages file updates and exc - `filter`: A string to use for filtering in the specified source path. It can contain `*` and `?` wildcards. _Example_: `deprecated-*.yaml` or `oldScript.ps1`. - `destinationFolder`: A path to a folder, relative to the end repository, where the files are located. If not specified, defaults to the same as the source file folder. _Example_: `.github/workflows`. - `perProject`: A boolean that indicates whether the matched files should be removed for all available AL-Go projects. In that case, `destinationFolder` is relative to the project folder. _Example_: `.AL-Go/scripts`. +- `destinationName`: The filename of the file at the destination. If specified, overrides the `filter` when looking up the file to remove. Should be used together with a `filter` that matches a single file. _Example_: `renamedScript.ps1`. > [!NOTE] > `filesToRemove` takes precedence over both `filesToInclude` and `filesToExclude`. If a file is matched by `filesToRemove`, it will be removed from the end repository even if it is also matched by `filesToInclude`. The file will also be excluded from the `filesToInclude` and `filesToExclude` lists, so it will not be created or updated. From afce6d75425a3dc40e3d712e9ca92628fd5cb06d Mon Sep 17 00:00:00 2001 From: Ole Wunschmann <44057549+OleWunschmann@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:52:01 +0200 Subject: [PATCH 06/34] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Tests/CheckForUpdates.Action.Test.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 858db6e9aa..63858f35c3 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -3281,7 +3281,7 @@ Describe "GetFilesToUpdate (real template)" { $filesToExclude.sourceFullPath | Should -Contain (Join-Path $originalTemplateFolder $_) } - # No files to exclude + # No files to remove $filesToRemove | Should -BeNullOrEmpty } From 00d1902b2c1b2f59a8551be3970f3e794a97099d Mon Sep 17 00:00:00 2001 From: OleWunschmann Date: Thu, 23 Jul 2026 21:51:39 +0200 Subject: [PATCH 07/34] Fixed copilot review comments --- .../CheckForUpdates.HelperFunctions.ps1 | 21 +- Scenarios/CustomizingALGoForGitHub.md | 7 +- Tests/CheckForUpdates.Action.Test.ps1 | 226 +++++++++++++++++- e2eTests/scenarios/CustomTemplate/runtest.ps1 | 4 +- 4 files changed, 240 insertions(+), 18 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index b14e1968be..6f32ab1166 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -1169,13 +1169,12 @@ function GetFilesToUpdate { $filesToIncludeUnresolved += $templateSettings.customALGoFiles.filesToInclude } $filesToIncludeUnresolved += $settings.customALGoFiles.filesToInclude - $filesToInclude = @() + $filesToInclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) if ($hasOriginalTemplate) { $filesToInclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) } - $filesToInclude += @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) - # Remove duplicates based on destinationFullPath, keeping the last one (setting > template settings > defaults; template folder > original template folder) - $filesToInclude = @($filesToInclude | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[-1] }) + # Remove duplicates based on destinationFullPath, keeping the first one (default > template settings > settings; template folder > original template folder) + $filesToInclude = @($filesToInclude | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[0] }) # Determine files to exclude $filesToExcludeUnresolved = GetDefaultFilesToExclude -settings $settings @@ -1183,13 +1182,12 @@ function GetFilesToUpdate { $filesToExcludeUnresolved += $templateSettings.customALGoFiles.filesToExclude } $filesToExcludeUnresolved += $settings.customALGoFiles.filesToExclude - $filesToExclude = @() + $filesToExclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) if ($hasOriginalTemplate) { $filesToExclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) } - $filesToExclude += @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) - # Remove duplicates based on destinationFullPath, keeping the last one (setting > template settings > defaults; template folder > original template folder) - $filesToExclude = @($filesToExclude | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[-1] }) + # Note: unlike filesToInclude/filesToRemove, filesToExclude is not deduplicated by destinationFullPath here. + # Its destinationFullPath is never part of the actual output; only sourceFullPath is used below to match against filesToInclude. # Determine files to remove $filesToRemoveUnresolved = @() @@ -1197,14 +1195,13 @@ function GetFilesToUpdate { $filesToRemoveUnresolved += $templateSettings.customALGoFiles.filesToRemove } $filesToRemoveUnresolved += $settings.customALGoFiles.filesToRemove - $filesToRemove = @() + $filesToRemove = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) if ($hasOriginalTemplate) { $filesToRemove += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) } - $filesToRemove += @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) $filesToRemove += @(ResolveFilePathsInDestinationFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) - # Remove duplicates based on destinationFullPath, keeping the last one (settings > template settings; base folder > template folder > original template folder) - $filesToRemove = @($filesToRemove | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[-1] }) + # Remove duplicates based on destinationFullPath, keeping the first one (default > template settings > settings; template folder > original template folder > base folder) + $filesToRemove = @($filesToRemove | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[0] }) # Exclude files from filesToInclude that are in filesToRemove (based on destination) $filesToInclude = @($filesToInclude | Where-Object { diff --git a/Scenarios/CustomizingALGoForGitHub.md b/Scenarios/CustomizingALGoForGitHub.md index c0e9227cea..3e90cdff50 100644 --- a/Scenarios/CustomizingALGoForGitHub.md +++ b/Scenarios/CustomizingALGoForGitHub.md @@ -244,7 +244,10 @@ In order to instruct AL-Go which files to look for at the template repository, y > [!NOTE] > `filesToInclude` is used to define all the template files that will be used by AL-Go for GitHub. If a template file is not matched, it will be ignored. Please pay attention, when changing the file configurations: there might be template files that were previously propagated to your repositories. In case these files are no longer matched via `filesToInclude`, AL-Go for GitHub will ignore them and you might have to remove them manually. -When using a custom template repository, `filesToInclude` also resolves files from the **original** AL-Go template (i.e. the official [AL-Go-PTE](https://github.com/microsoft/AL-Go-PTE) or [AL-Go-AppSource](https://github.com/microsoft/AL-Go-AppSource) template). This means files present in the official AL-Go template that are not overridden by your custom template are still propagated to consumer repositories. When a file exists in both the original template and your custom template, the custom template version takes precedence. +When using a custom template repository, `filesToInclude` also resolves files from the **original** AL-Go template (i.e. the official [AL-Go-PTE](https://github.com/microsoft/AL-Go-PTE) or [AL-Go-AppSource](https://github.com/microsoft/AL-Go-AppSource) template). This means files present in the official AL-Go template that are not overridden by your custom template are still propagated to consumer repositories. When a file exists in both the original template and your custom template, how the file's **content** is resolved depends on the file's type: + +- **Workflow files** (`.github/workflows/*.yaml`/`*.yml`): the content is based on the original template's file, with customizations from your custom template's copy (see [Adding custom jobs](#adding-custom-jobs)) re-applied on top. +- **Settings files** and **all other files** (e.g. PowerShell scripts, `.copy.md`, `.agent.md`): the original template's file content is used as-is; changes made to that same file in your custom template are not applied in this case. The following table summarizes how `filesToInclude` resolves files when a custom template is in use: @@ -252,7 +255,7 @@ The following table summarizes how `filesToInclude` resolves files when a custom |---|---|---|---| | Yes | No | Yes | File from **original template** is propagated | | No | Yes | Yes | File from **custom template** is propagated | -| Yes | Yes | Yes | File from **custom template** is used (takes precedence) | +| Yes | Yes | Yes | File from **original template** is propagated; for Workflow files, customizations from the **custom template** are also applied | | Yes/No | Yes/No | No | File is **ignored** | `filesToExclude` is an array of file configurations that will instruct AL-Go which files to exclude (remove) from `filesToInclude`. Every item in the array may contain the following properties: diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 63858f35c3..3d802678d8 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -1490,7 +1490,7 @@ Describe "ResolveFilePaths" { } It 'ResolveFilePaths skips files in folder whose name starts with source folder name' { - # Create a external file in folder whose name starts with the same prefix as sourceFolder + # Create an external file in a folder whose name starts with the same prefix as sourceFolder $externalFolder = "${sourceFolder}-external" if (-not (Test-Path $externalFolder)) { New-Item -Path $externalFolder -ItemType Directory | Out-Null } $externalFile = Join-Path $externalFolder "outside.txt" @@ -2633,7 +2633,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - # filesToRemove should one entry resolved from the base folder + # filesToRemove should have one entry resolved from the base folder $filesToRemove | Should -Not -BeNullOrEmpty $filesToRemove.Count | Should -Be 1 $filesToRemove[0].sourceFullPath | Should -Be (Join-Path $baseFolder "test.txt") @@ -2916,6 +2916,129 @@ Describe "GetFilesToUpdate (general files to update logic)" { $filesToRemove.destinationFullPath | Should -Contain $testBasePSFile } + It 'GetFilesToUpdate template settings filesToInclude takes precedence over repository settings for the same destination' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt"; destinationName = "conflict.txt" }) + filesToExclude = @() + filesToRemove = @() + } + } + + $templateSettings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.ps1"; destinationName = "conflict.txt" }) + filesToExclude = @() + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings + + # Only one entry should be resolved for the colliding destination + $conflict = @($filesToInclude | Where-Object { $_.destinationFullPath -eq (Join-Path $baseFolder "conflict.txt") }) + $conflict.Count | Should -Be 1 + + # Template settings should win over repository settings for the same destination + $conflict[0].sourceFullPath | Should -Be $testPSFile + } + + It 'GetFilesToUpdate template settings filesToExclude added to filesToExclude' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }, @{ filter = "test.ps1" }) + filesToExclude = @(@{ filter = "test.txt" }) + filesToRemove = @() + } + } + + $templateSettings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @(@{ filter = "test.ps1" }) + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings + + # Both settings' and templateSettings' exclude entries take effect since they don't collide + $filesToExclude.sourceFullPath | Should -Contain $testTxtFile + $filesToExclude.sourceFullPath | Should -Contain $testPSFile + $filesToInclude | Should -BeNullOrEmpty + } + + It 'GetFilesToUpdate template settings filesToExclude and repository settings filesToExclude for the same source file are both applied without duplicates' { + # Both templateSettings and settings exclude the exact same source file (test.txt). This should not error out + # or produce a duplicate entry: the file should end up excluded exactly once, and it should not remain in + # filesToInclude. + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }, @{ filter = "test.ps1" }) + filesToExclude = @(@{ filter = "test.txt" }) + filesToRemove = @() + } + } + + $templateSettings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @(@{ filter = "test.txt" }) + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings + + # test.txt is excluded exactly once, even though both templateSettings and settings exclude it + @($filesToExclude | Where-Object { $_.sourceFullPath -eq $testTxtFile }).Count | Should -Be 1 + $filesToInclude.sourceFullPath | Should -Not -Contain $testTxtFile + $filesToInclude.sourceFullPath | Should -Contain $testPSFile + } + + It 'GetFilesToUpdate template settings filesToRemove takes precedence over repository settings for the same destination' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + filesToRemove = @(@{ filter = "test.txt"; destinationName = "conflict.remove.txt" }) + } + } + + $templateSettings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + filesToRemove = @(@{ filter = "test.ps1"; destinationName = "conflict.remove.txt" }) + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings + + # Only one entry should be resolved for the colliding destination + $conflict = @($filesToRemove | Where-Object { $_.destinationFullPath -eq (Join-Path $baseFolder "conflict.remove.txt") }) + $conflict.Count | Should -Be 1 + + # Template settings should win over repository settings for the same destination + $conflict[0].sourceFullPath | Should -Be $testPSFile + } + It 'GetFilesToUpdate template settings unusedALGoSystemFiles added to unusedALGoSystemFiles' { $settings = @{ type = "NotPTE" @@ -3084,6 +3207,105 @@ Describe "GetFilesToUpdate (real template)" { $filesToRemove | Should -BeNullOrEmpty } + It 'GetFilesToUpdate defaults filesToInclude takes precedence over repository settings for the same destination' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = "PowerPlatformSolution" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + # Redirect a different template file onto the destination of the default AL-Go-Settings.json entry + filesToInclude = @(@{ filter = "Test Next Major.settings.json"; sourceFolder = ".github"; destinationFolder = ".github"; destinationName = "$RepoSettingsFileName" }) + filesToExclude = @() + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + + $repoSettingsDestination = Join-Path 'baseFolder' (Join-Path '.github' $RepoSettingsFileName) + $conflict = @($filesToInclude | Where-Object { $_.destinationFullPath -eq $repoSettingsDestination }) + $conflict.Count | Should -Be 1 + + # The default entry should win over the repository settings entry for the same destination + $conflict[0].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder (Join-Path '.github' $RepoSettingsFileName)) + } + + It 'GetFilesToUpdate defaults filesToInclude takes precedence over template settings for the same destination' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = "PowerPlatformSolution" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + filesToRemove = @() + } + } + + $templateSettings = @{ + type = "PTE" + powerPlatformSolutionFolder = "PowerPlatformSolution" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + # Redirect a different template file onto the destination of the default AL-Go-Settings.json entry + filesToInclude = @(@{ filter = "Test Next Major.settings.json"; sourceFolder = ".github"; destinationFolder = ".github"; destinationName = "$RepoSettingsFileName" }) + filesToExclude = @() + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -templateSettings $templateSettings + + $repoSettingsDestination = Join-Path 'baseFolder' (Join-Path '.github' $RepoSettingsFileName) + $conflict = @($filesToInclude | Where-Object { $_.destinationFullPath -eq $repoSettingsDestination }) + $conflict.Count | Should -Be 1 + + # The default entry should win over the template settings entry for the same destination + $conflict[0].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder (Join-Path '.github' $RepoSettingsFileName)) + } + + It 'GetFilesToUpdate defaults filesToExclude combined with repository settings filesToExclude for non-colliding files' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = '' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @(@{ filter = "_BuildALGoProject.yaml"; sourceFolder = ".github/workflows" }) + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + + # The default exclude entries (PowerPlatform files) and the repository settings' own exclude entry are both applied + $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $powerPlatformFiles[0]) + $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/_BuildALGoProject.yaml") + $filesToInclude.sourceFullPath | Should -Not -Contain (Join-Path $realPTETemplateFolder ".github/workflows/_BuildALGoProject.yaml") + } + + It 'GetFilesToUpdate defaults filesToExclude and repository settings filesToExclude for the same source file are both applied without duplicates' { + # The repository settings entry excludes the exact same file that the default PowerPlatform exclude entries + # already exclude (since powerPlatformSolutionFolder is empty). This should not error out or produce a + # duplicate entry: the file should end up excluded exactly once. + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = '' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @(@{ filter = [System.IO.Path]::GetFileName($powerPlatformFiles[0]); sourceFolder = [System.IO.Path]::GetDirectoryName($powerPlatformFiles[0]).Replace('\', '/') }) + filesToRemove = @() + } + } + + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + + $ppFileSourcePath = Join-Path $realPTETemplateFolder $powerPlatformFiles[0] + @($filesToExclude | Where-Object { $_.sourceFullPath -eq $ppFileSourcePath }).Count | Should -Be 1 + $filesToInclude.sourceFullPath | Should -Not -Contain $ppFileSourcePath + } + It 'Return PP files in filesToExclude when type is PTE but powerPlatformSolutionFolder is empty' { $settings = @{ type = "PTE" diff --git a/e2eTests/scenarios/CustomTemplate/runtest.ps1 b/e2eTests/scenarios/CustomTemplate/runtest.ps1 index b58278402e..6a3e506ab8 100644 --- a/e2eTests/scenarios/CustomTemplate/runtest.ps1 +++ b/e2eTests/scenarios/CustomTemplate/runtest.ps1 @@ -361,7 +361,7 @@ Remove-Item -Path (Join-Path (Get-Location) $missingWorkflowFileRelativePath) -F (Join-Path (Get-Location) $defaultCustomFileName) | Should -Not -Exist # Check that optional custom file is NOT present in final repository (Join-Path (Get-Location) $optionalCustomFileName) | Should -Not -Exist -# Check that legacy workflow file is present in final repository +# Check that legacy custom file is present in final repository (Join-Path (Get-Location) $legacyCustomFileName) | Should -Exist # Check that excluded workflow file is present in final repository @@ -433,7 +433,7 @@ Pull (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $finalRepoCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") -# Check that default custom file is NOT present (in repos's filesToExclude and template's filesToInclude) + # Check that default custom file is NOT present (in repo's filesToExclude and template's filesToInclude) (Join-Path (Get-Location) $defaultCustomFileName) | Should -Not -Exist # Check that optional custom file is present (in repos's filesToInclude) (Join-Path (Get-Location) $optionalCustomFileName) | Should -Exist From c8ded0dcb9cef700b77e96602cdc31f93e3e9805 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Fri, 24 Jul 2026 14:07:14 +0200 Subject: [PATCH 08/34] Fix copilot review comments --- .../CheckForUpdates.HelperFunctions.ps1 | 78 ++--- Actions/CheckForUpdates/CheckForUpdates.ps1 | 75 +++-- RELEASENOTES.md | 4 +- Tests/CheckForUpdates.Action.Test.ps1 | 291 ++++-------------- e2eTests/scenarios/CustomTemplate/runtest.ps1 | 20 +- 5 files changed, 157 insertions(+), 311 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 6f32ab1166..735bad6bda 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -1081,18 +1081,52 @@ function GetDefaultFilesToExclude { return @($filesToExclude) } +<# +.SYNOPSIS + Refreshes the on-disk snapshot of a custom template's own repository settings. +.DESCRIPTION + Overwrites /.github/AL-Go-TemplateRepoSettings.doNotEdit.json with the current content of + /.github/AL-Go-Settings.json, so that a subsequent ReadSettings call picks up the + custom template's up-to-date settings (customALGoFiles, unusedALGoSystemFiles, etc.) instead of the + last-committed (potentially stale) snapshot. No-op if the template does not have its own settings file. +.PARAMETER baseFolder + The base folder of the repository (the target folder where the snapshot file is (over)written). +.PARAMETER templateFolder + The folder where the (custom) template files are located (the source of the fresh settings). +#> +function UpdateCustomTemplateRepoSettingsSnapshot { + Param( + [Parameter(Mandatory=$true)] + [string] $baseFolder, + [Parameter(Mandatory=$true)] + [string] $templateFolder + ) + + $srcFile = Join-Path $templateFolder (Join-Path '.github' $RepoSettingsFileName) + if (Test-Path -Path $srcFile -PathType Leaf) { + $dstFile = Join-Path $baseFolder (Join-Path '.github' $CustomTemplateRepoSettingsFileName) + Write-Host "Refreshing $CustomTemplateRepoSettingsFileName from the custom template's up-to-date $RepoSettingsFileName" + Get-ContentLF -path $srcFile | Set-ContentLF -path $dstFile + } +} + <# .SYNOPSIS Compute the lists of files to include, exclude, and remove when synchronizing a repository from its AL-Go template. .DESCRIPTION - Builds three lists by merging defaults, repository settings, template settings, and the original AL-Go template (if given): + Builds three lists by merging defaults, repository settings, and the original AL-Go template (if given): 1. filesToInclude: Files to copy from the template or original template to the destination. - Built from default files to include and customALGoFiles.filesToInclude in settings and templateSettings, resolved against the template folder and original template folder (if any). + Built from default files to include and customALGoFiles.filesToInclude in settings, resolved against the template folder and original template folder (if any). 2. filesToExclude: Files to skip from copying; if they already exist in the destination they should be deleted. - Built from default files to exclude and customALGoFiles.filesToExclude in settings and templateSettings, resolved against the template folder and original template folder (if any). + Built from default files to exclude and customALGoFiles.filesToExclude in settings, resolved against the template folder and original template folder (if any). 3. filesToRemove: Files to unconditionally delete from the destination. - Built from customALGoFiles.filesToRemove in settings and templateSettings and resolved against template folder, the original template folder (if any), and the destination folder. + Built from customALGoFiles.filesToRemove in settings and resolved against template folder, the original template folder (if any), and the destination folder. + + Note: when a custom template is in use, the caller is expected to have already refreshed + .github/AL-Go-TemplateRepoSettings.doNotEdit.json (see UpdateCustomTemplateRepoSettingsSnapshot) and + re-read settings before calling this function, so that the template's customALGoFiles/unusedALGoSystemFiles + are already merged into settings. The deprecated unusedALGoSystemFiles setting is also applied: matching files are moved from filesToInclude to filesToExclude with a deprecation warning. @@ -1103,9 +1137,6 @@ function GetDefaultFilesToExclude { The base folder of the repository. This is the target folder where the files will be updated. .PARAMETER templateFolder The folder where the template files are located. -.PARAMETER templateSettings - The settings object from the template repository (if any). Both customALGoFiles and unusedALGoSystemFiles - are merged from this object when it is provided. .PARAMETER originalTemplateFolder The folder where the original AL-Go template files are located (if any). When provided, it signals that a custom template is in use. Both filesToInclude and filesToExclude specs are @@ -1132,7 +1163,6 @@ function GetFilesToUpdate { $baseFolder, [Parameter(Mandatory=$true)] $templateFolder, - $templateSettings = $null, $originalTemplateFolder = $null, $projects = @() ) @@ -1151,36 +1181,18 @@ function GetFilesToUpdate { Trace-Information -Message "Usage: Custom AL-Go Files (Remove) of repository" } - if ($null -ne $templateSettings) { - if ($templateSettings.customALGoFiles.filesToInclude.Count -gt 0) { - Trace-Information -Message "Usage: Custom AL-Go Files (Include) of template" - } - if ($templateSettings.customALGoFiles.filesToExclude.Count -gt 0) { - Trace-Information -Message "Usage: Custom AL-Go Files (Exclude) of template" - } - if ($templateSettings.customALGoFiles.filesToRemove.Count -gt 0) { - Trace-Information -Message "Usage: Custom AL-Go Files (Remove) of template" - } - } - # Determine files to include $filesToIncludeUnresolved = GetDefaultFilesToInclude -includeCustomTemplateFiles:$hasOriginalTemplate - if ($null -ne $templateSettings) { - $filesToIncludeUnresolved += $templateSettings.customALGoFiles.filesToInclude - } $filesToIncludeUnresolved += $settings.customALGoFiles.filesToInclude $filesToInclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) if ($hasOriginalTemplate) { $filesToInclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) } - # Remove duplicates based on destinationFullPath, keeping the first one (default > template settings > settings; template folder > original template folder) + # Remove duplicates based on destinationFullPath, keeping the first one (default > settings; template folder > original template folder) $filesToInclude = @($filesToInclude | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[0] }) # Determine files to exclude $filesToExcludeUnresolved = GetDefaultFilesToExclude -settings $settings - if ($null -ne $templateSettings) { - $filesToExcludeUnresolved += $templateSettings.customALGoFiles.filesToExclude - } $filesToExcludeUnresolved += $settings.customALGoFiles.filesToExclude $filesToExclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) if ($hasOriginalTemplate) { @@ -1190,17 +1202,13 @@ function GetFilesToUpdate { # Its destinationFullPath is never part of the actual output; only sourceFullPath is used below to match against filesToInclude. # Determine files to remove - $filesToRemoveUnresolved = @() - if ($null -ne $templateSettings) { - $filesToRemoveUnresolved += $templateSettings.customALGoFiles.filesToRemove - } - $filesToRemoveUnresolved += $settings.customALGoFiles.filesToRemove + $filesToRemoveUnresolved = @($settings.customALGoFiles.filesToRemove) $filesToRemove = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) if ($hasOriginalTemplate) { $filesToRemove += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) } $filesToRemove += @(ResolveFilePathsInDestinationFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) - # Remove duplicates based on destinationFullPath, keeping the first one (default > template settings > settings; template folder > original template folder > base folder) + # Remove duplicates based on destinationFullPath, keeping the first one (default > settings; template folder > original template folder > base folder) $filesToRemove = @($filesToRemove | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[0] }) # Exclude files from filesToInclude that are in filesToRemove (based on destination) @@ -1227,11 +1235,7 @@ function GetFilesToUpdate { }) # Apply unusedALGoSystemFiles logic - # Include unusedALGoSystemFiles from the template settings (if any) to also propagate template's deprecated file removals $unusedALGoSystemFiles = @($settings.unusedALGoSystemFiles) - if ($null -ne $templateSettings) { - $unusedALGoSystemFiles += @($templateSettings.unusedALGoSystemFiles) - } # Exclude unusedALGoSystemFiles from $filesToInclude and add them to $filesToExclude $unusedFilesToExclude = $filesToInclude | Where-Object { $unusedALGoSystemFiles -contains (Split-Path -Path $_.sourceFullPath -Leaf) } diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index bef77b9a4d..e08613fcb5 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -72,49 +72,62 @@ $templateInfo = "$templateOwner/$($templateUrl.Split('/')[4])" $isDirectALGo = IsDirectALGo -templateUrl $templateUrl if (-not $isDirectALGo) { - # Get template Repo settings as a hashtable (do NOT read any variable settings, specific project settings, nor any specific workflow, user or branch settings) - $templateRepoSettings = ReadSettings -baseFolder $templateFolder -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' -orgSettingsVariableValue '' -repoSettingsVariableValue '' -environmentSettingsVariableValue '' | ConvertTo-HashTable -recurse - if ($templateRepoSettings.templateUrl -and $templateRepoSettings.templateUrl -ne $templateUrl) { - # The template repository is a url to another AL-Go repository (a custom template repository) - Trace-Information -Message "Using custom AL-Go template repository" - - # TemplateUrl and TemplateSha from .github/AL-Go-Settings.json in the custom template repository points to the "original" template repository - # Copy files and folders from the custom template repository, but grab the unmodified file from the "original" template repository if it exists and apply customizations - # Copy .github/AL-Go-Settings.json to .github/templateRepoSettings.doNotEdit.json (will be read before .github/AL-Go-Settings.json in the final repo) - # Copy .AL-Go/settings.json to .github/templateProjectSettings.doNotEdit.json (will be read before .AL-Go/settings.json in the final repo) - - Write-Host "Custom AL-Go template repository detected, downloading the 'original' template repository" - $originalTemplateUrl = $templateRepoSettings.templateUrl - if ($templateRepoSettings.Keys -contains "templateSha") { - $originalTemplateSha = $templateRepoSettings.templateSha - } - else { - $originalTemplateSha = "" - } + $templateRepoSettingsFile = Join-Path $templateFolder $RepoSettingsFile + if (Test-Path -Path $templateRepoSettingsFile -PathType Leaf) { + $templateRepoSettings = Get-Content $templateRepoSettingsFile -Encoding UTF8 | ConvertFrom-Json | ConvertTo-HashTable -Recurse + if ($templateRepoSettings.Keys -contains "templateUrl" -and $templateRepoSettings.templateUrl -ne $templateUrl) { + # The template repository is a url to another AL-Go repository (a custom template repository) + Trace-Information -Message "Using custom AL-Go template repository" + + # TemplateUrl and TemplateSha from .github/AL-Go-Settings.json in the custom template repository points to the "original" template repository + # Copy files and folders from the custom template repository, but grab the unmodified file from the "original" template repository if it exists and apply customizations + # Copy .github/AL-Go-Settings.json to .github/templateRepoSettings.doNotEdit.json (will be read before .github/AL-Go-Settings.json in the final repo) + # Copy .AL-Go/settings.json to .github/templateProjectSettings.doNotEdit.json (will be read before .AL-Go/settings.json in the final repo) + + Write-Host "Custom AL-Go template repository detected, downloading the 'original' template repository" + $originalTemplateUrl = $templateRepoSettings.templateUrl + if ($templateRepoSettings.Keys -contains "templateSha") { + $originalTemplateSha = $templateRepoSettings.templateSha + } + else { + $originalTemplateSha = "" + } - # Download the "original" template repository - use downloadLatest if no TemplateSha is specified in the custom template repository - $originalTemplateFolder = DownloadTemplateRepository -token $token -templateUrl $originalTemplateUrl -templateSha ([ref]$originalTemplateSha) -downloadLatest ($originalTemplateSha -eq '') - $originalTemplateFolder = GetSrcFolder -repoType $repoSettings.type -templateUrl $originalTemplateUrl -templateFolder $originalTemplateFolder + # Download the "original" template repository - use downloadLatest if no TemplateSha is specified in the custom template repository + $originalTemplateFolder = DownloadTemplateRepository -token $token -templateUrl $originalTemplateUrl -templateSha ([ref]$originalTemplateSha) -downloadLatest ($originalTemplateSha -eq '') + $originalTemplateFolder = GetSrcFolder -repoType $repoSettings.type -templateUrl $originalTemplateUrl -templateFolder $originalTemplateFolder - Write-Host "Original Template Folder: $originalTemplateFolder" + Write-Host "Original Template Folder: $originalTemplateFolder" - # Set TemplateBranch and TemplateOwner - # Keep TemplateUrl and TemplateSha pointing to the custom template repository - $templateBranch = $originalTemplateUrl.Split('@')[1] - $templateOwner = $originalTemplateUrl.Split('/')[3] + # Set TemplateBranch and TemplateOwner + # Keep TemplateUrl and TemplateSha pointing to the custom template repository + $templateBranch = $originalTemplateUrl.Split('@')[1] + $templateOwner = $originalTemplateUrl.Split('/')[3] - $isDirectALGo = IsDirectALGo -templateUrl $originalTemplateUrl - if ($isDirectALGo) { - Trace-Information -Message "Original template repository is direct AL-Go" + $isDirectALGo = IsDirectALGo -templateUrl $originalTemplateUrl + if ($isDirectALGo) { + Trace-Information -Message "Original template repository is direct AL-Go" + } } } } # Get the list of projects in the current repository $baseFolder = $ENV:GITHUB_WORKSPACE + +if ($originalTemplateFolder) { + # A custom template is in use. Refresh the on-disk snapshot of the custom template's repo settings + # (.github/AL-Go-TemplateRepoSettings.doNotEdit.json) with the up-to-date content from the template + # we just downloaded, and re-read repo settings, so this run resolves customALGoFiles / + # unusedALGoSystemFiles using the current template settings instead of the last-committed + # (potentially stale) snapshot. + UpdateCustomTemplateRepoSettingsSnapshot -baseFolder $baseFolder -templateFolder $templateFolder + $repoSettings = ReadSettings -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse +} + $projects = @(GetProjectsFromRepository -baseFolder $baseFolder -projectsFromSettings $repoSettings.projects) -$filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $repoSettings -projects $projects -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateRepoSettings -originalTemplateFolder $originalTemplateFolder +$filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $repoSettings -projects $projects -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder # $updateFiles will hold an array of files, which needs to be updated $updateFiles = @() diff --git a/RELEASENOTES.md b/RELEASENOTES.md index a48dcb7134..b8d848ae07 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,10 +2,10 @@ The `customALGoFiles` setting of a custom template was only applied on the next Update (from `AL-Go-TemplateRepoSettings.doNotEdit.json`). Now the up-to-date settings of the custom template are used directly during "Update AL-Go System Files". The template's `filesToInclude`, `filesToExclude`, and `filesToRemove` settings are merged with the consumer repo's settings before resolution. -- **`filesToInclude`** now also resolves files from the original AL-Go template. Files present in the official template that are not overridden by your custom template are propagated to consumer repos. When a file exists in both, the custom template version takes precedence. +- **`filesToInclude`** now also resolves files from the original AL-Go template. Files present in the official template are propagated even when they are absent from your custom template. When a file exists in both, the official template supplies the base content; for workflow files, customizations from the custom template are reapplied. - **`filesToExclude`** now also resolves files from the original AL-Go template (same dual-resolution as `filesToInclude`). Files resolved by `filesToInclude` whose source matches a `filesToExclude` entry are not copied to consumer repos, and existing copies are removed. - **`filesToRemove`** (new property): Unconditionally removes matching files from consumer repos. Files are searched in both the template and end repository. Takes precedence over `filesToInclude`. Entries use `sourceFolder` (relative to the template), `filter`, and optionally `destinationFolder`, `perProject`, and `destinationName`. -- **`destinationName`** (new property on `filesToInclude` and `filesToRemove`): Allows renaming a file at the destination. When set, the file is written to (or removed from) `/` instead of keeping the source filename. For `filesToRemove`, `destinationName` also overrides the `filter` when looking up the file to delete. +- **`destinationName`** (new property on `filesToInclude` and `filesToRemove`): Allows renaming a file at the destination. When set, the file is written to (or removed from) `/` instead of keeping the source filename. For `filesToRemove`, `destinationName` also overrides the `filter` when looking up the file to delete in the end repository. Read more at [Customizing AL-Go for GitHub](Scenarios/CustomizingALGoForGitHub.md#Using-custom-template-files). diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 3d802678d8..5b1f8564f5 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -2620,7 +2620,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { $testTxtFiles[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'folder2/test.txt') } - It 'GetFilesToUpdate filesToRemove resolves files from base folder' { + It 'GetFilesToUpdate filesToRemove resolves from template folder when file exists in both template and base folder' { $settings = @{ type = "NotPTE" unusedALGoSystemFiles = @() @@ -2633,10 +2633,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - # filesToRemove should have one entry resolved from the base folder + # filesToRemove should have one entry resolved from the template folder $filesToRemove | Should -Not -BeNullOrEmpty $filesToRemove.Count | Should -Be 1 - $filesToRemove[0].sourceFullPath | Should -Be (Join-Path $baseFolder "test.txt") + $filesToRemove[0].sourceFullPath | Should -Be $testTxtFile $filesToRemove[0].destinationFullPath | Should -Be (Join-Path $baseFolder "test.txt") $filesToRemove[0].originalSourceFullPath | Should -Be $null @@ -2772,9 +2772,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + # filesToRemove should have one entry resolved from the template folder with the specified destinationFolder and destinationName $filesToRemove | Should -Not -BeNullOrEmpty $filesToRemove.Count | Should -Be 1 - $filesToRemove[0].sourceFullPath | Should -Be (Join-Path $baseFolder "subfolder/testsub.txt") + $filesToRemove[0].sourceFullPath | Should -Be $testTxtFile $filesToRemove[0].destinationFullPath | Should -Be (Join-Path $baseFolder "subfolder/testsub.txt") $filesToRemove[0].originalSourceFullPath | Should -Be $null } @@ -2832,241 +2833,48 @@ Describe "GetFilesToUpdate (general files to update logic)" { $filesToRemove | Should -BeNullOrEmpty } - It 'GetFilesToUpdate template settings filesToInclude added to filesToInclude' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.txt" }) - filesToExclude = @() - filesToRemove = @() - } - } - - $templateSettings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.ps1" }) - filesToExclude = @() - filesToRemove = @() - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings - - # Both test.txt and test.ps1 should be in filesToInclude - $filesToInclude.sourceFullPath | Should -Contain $testTxtFile - $filesToInclude.sourceFullPath | Should -Contain $testPSFile - } - - It 'GetFilesToUpdate template settings filesToExclude added to filesToExclude' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.txt" }, @{ filter = "test.ps1" }) - filesToExclude = @(@{ filter = "test.txt" }) - filesToRemove = @() - } - } - - $templateSettings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @(@{ filter = "test.ps1" }) - filesToRemove = @() - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings - - # Both test.txt and test.ps1 should be in filesToExclude - $filesToExclude.sourceFullPath | Should -Contain $testTxtFile - $filesToExclude.sourceFullPath | Should -Contain $testPSFile - } - - It 'GetFilesToUpdate template settings filesToRemove added to filesToRemove' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @() - filesToRemove = @(@{ filter = "test.base.txt" }) - } - } - - $templateSettings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @() - filesToRemove = @(@{ filter = "test.base.ps1" }) - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings - - # Both test.base.txt and test.base.ps1 should be in filesToRemove - $filesToRemove.destinationFullPath | Should -Contain $testBaseTxtFile - $filesToRemove.destinationFullPath | Should -Contain $testBasePSFile - } - - It 'GetFilesToUpdate template settings filesToInclude takes precedence over repository settings for the same destination' { + It 'GetFilesToUpdate filesToInclude keeps the first entry when two entries collide on the same destination' { $settings = @{ type = "NotPTE" unusedALGoSystemFiles = @() customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.txt"; destinationName = "conflict.txt" }) + filesToInclude = @(@{ filter = "test.ps1"; destinationName = "conflict.txt" }, @{ filter = "test.txt"; destinationName = "conflict.txt" }) filesToExclude = @() filesToRemove = @() } } - $templateSettings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.ps1"; destinationName = "conflict.txt" }) - filesToExclude = @() - filesToRemove = @() - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # Only one entry should be resolved for the colliding destination $conflict = @($filesToInclude | Where-Object { $_.destinationFullPath -eq (Join-Path $baseFolder "conflict.txt") }) $conflict.Count | Should -Be 1 - # Template settings should win over repository settings for the same destination + # The first-listed entry should win over the later entry for the same destination $conflict[0].sourceFullPath | Should -Be $testPSFile } - It 'GetFilesToUpdate template settings filesToExclude added to filesToExclude' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.txt" }, @{ filter = "test.ps1" }) - filesToExclude = @(@{ filter = "test.txt" }) - filesToRemove = @() - } - } - - $templateSettings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @(@{ filter = "test.ps1" }) - filesToRemove = @() - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings - - # Both settings' and templateSettings' exclude entries take effect since they don't collide - $filesToExclude.sourceFullPath | Should -Contain $testTxtFile - $filesToExclude.sourceFullPath | Should -Contain $testPSFile - $filesToInclude | Should -BeNullOrEmpty - } - - It 'GetFilesToUpdate template settings filesToExclude and repository settings filesToExclude for the same source file are both applied without duplicates' { - # Both templateSettings and settings exclude the exact same source file (test.txt). This should not error out - # or produce a duplicate entry: the file should end up excluded exactly once, and it should not remain in - # filesToInclude. - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.txt" }, @{ filter = "test.ps1" }) - filesToExclude = @(@{ filter = "test.txt" }) - filesToRemove = @() - } - } - - $templateSettings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @(@{ filter = "test.txt" }) - filesToRemove = @() - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings - - # test.txt is excluded exactly once, even though both templateSettings and settings exclude it - @($filesToExclude | Where-Object { $_.sourceFullPath -eq $testTxtFile }).Count | Should -Be 1 - $filesToInclude.sourceFullPath | Should -Not -Contain $testTxtFile - $filesToInclude.sourceFullPath | Should -Contain $testPSFile - } - - It 'GetFilesToUpdate template settings filesToRemove takes precedence over repository settings for the same destination' { + It 'GetFilesToUpdate filesToRemove keeps the first entry when two entries collide on the same destination' { $settings = @{ type = "NotPTE" unusedALGoSystemFiles = @() customALGoFiles = @{ filesToInclude = @() filesToExclude = @() - filesToRemove = @(@{ filter = "test.txt"; destinationName = "conflict.remove.txt" }) - } - } - - $templateSettings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @() - filesToRemove = @(@{ filter = "test.ps1"; destinationName = "conflict.remove.txt" }) + filesToRemove = @(@{ filter = "test.ps1"; destinationName = "conflict.remove.txt" }, @{ filter = "test.txt"; destinationName = "conflict.remove.txt" }) } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings + $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # Only one entry should be resolved for the colliding destination $conflict = @($filesToRemove | Where-Object { $_.destinationFullPath -eq (Join-Path $baseFolder "conflict.remove.txt") }) $conflict.Count | Should -Be 1 - # Template settings should win over repository settings for the same destination + # The first-listed entry should win over the later entry for the same destination $conflict[0].sourceFullPath | Should -Be $testPSFile } - It 'GetFilesToUpdate template settings unusedALGoSystemFiles added to unusedALGoSystemFiles' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @("test.txt") - customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.txt" }) - filesToExclude = @() - filesToRemove = @() - } - } - - $templateSettings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @("test.ps1") - customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.ps1" }) - filesToExclude = @() - filesToRemove = @() - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -templateSettings $templateSettings - - # Both test.txt and test.ps1 should be in filesToExclude - $filesToExclude.sourceFullPath | Should -Contain $testTxtFile - $filesToExclude.sourceFullPath | Should -Contain $testPSFile - } - It 'GetFilesToUpdate filesToInclude includes original template files missing in template' { $settings = @{ type = "NotPTE" @@ -3162,6 +2970,45 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } +Describe "UpdateCustomTemplateRepoSettingsSnapshot" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + } + + It 'Overwrites the destination snapshot file with the current content of the template settings file' { + $templateFolder = Join-Path "TestDrive:" "template" + $baseFolder = Join-Path "TestDrive:" "base" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + + $srcFile = Join-Path $templateFolder (Join-Path ".github" $RepoSettingsFileName) + Set-Content -Path $srcFile -Value '{"type":"PTE","customALGoFiles":{"filesToInclude":[]}}' -Encoding UTF8 + + $dstFile = Join-Path $baseFolder (Join-Path ".github" $CustomTemplateRepoSettingsFileName) + Set-Content -Path $dstFile -Value '{"type":"PTE","customALGoFiles":{"filesToInclude":[{"filter":"old.txt"}]}}' -Encoding UTF8 + + UpdateCustomTemplateRepoSettingsSnapshot -baseFolder $baseFolder -templateFolder $templateFolder + + Test-Path -Path $dstFile -PathType Leaf | Should -Be $true + (Get-ContentLF -path $dstFile) | Should -Be (Get-ContentLF -path $srcFile) + } + + It 'Does not throw and does not create a destination file when the template has no settings file' { + $templateFolder = Join-Path "TestDrive:" "templateWithoutSettings" + $baseFolder = Join-Path "TestDrive:" "baseWithoutSettings" + New-Item -ItemType Directory -Path $templateFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + + $dstFile = Join-Path $baseFolder (Join-Path ".github" $CustomTemplateRepoSettingsFileName) + + { UpdateCustomTemplateRepoSettingsSnapshot -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Not -Throw + + Test-Path -Path $dstFile -PathType Leaf | Should -Be $false + } +} + Describe "GetFilesToUpdate (real template)" { BeforeAll { $actionName = "CheckForUpdates" @@ -3230,40 +3077,6 @@ Describe "GetFilesToUpdate (real template)" { $conflict[0].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder (Join-Path '.github' $RepoSettingsFileName)) } - It 'GetFilesToUpdate defaults filesToInclude takes precedence over template settings for the same destination' { - $settings = @{ - type = "PTE" - powerPlatformSolutionFolder = "PowerPlatformSolution" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @() - filesToRemove = @() - } - } - - $templateSettings = @{ - type = "PTE" - powerPlatformSolutionFolder = "PowerPlatformSolution" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - # Redirect a different template file onto the destination of the default AL-Go-Settings.json entry - filesToInclude = @(@{ filter = "Test Next Major.settings.json"; sourceFolder = ".github"; destinationFolder = ".github"; destinationName = "$RepoSettingsFileName" }) - filesToExclude = @() - filesToRemove = @() - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -templateSettings $templateSettings - - $repoSettingsDestination = Join-Path 'baseFolder' (Join-Path '.github' $RepoSettingsFileName) - $conflict = @($filesToInclude | Where-Object { $_.destinationFullPath -eq $repoSettingsDestination }) - $conflict.Count | Should -Be 1 - - # The default entry should win over the template settings entry for the same destination - $conflict[0].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder (Join-Path '.github' $RepoSettingsFileName)) - } - It 'GetFilesToUpdate defaults filesToExclude combined with repository settings filesToExclude for non-colliding files' { $settings = @{ type = "PTE" diff --git a/e2eTests/scenarios/CustomTemplate/runtest.ps1 b/e2eTests/scenarios/CustomTemplate/runtest.ps1 index 6a3e506ab8..974eb34744 100644 --- a/e2eTests/scenarios/CustomTemplate/runtest.ps1 +++ b/e2eTests/scenarios/CustomTemplate/runtest.ps1 @@ -221,7 +221,8 @@ $missingWorkflowFile = Join-Path $templateRepoPath $missingWorkflowFileRelativeP Remove-Item -Path $missingWorkflowFile -Force | Out-Null # Add customALGoFiles settings to the template repository -$null = Add-PropertiesToJsonFile -path '.github/AL-Go-Settings.json' -properties @{ +$templateRepoSettingsFile = Join-Path $templateRepoPath $RepoSettingsFile +$null = Add-PropertiesToJsonFile -path $templateRepoSettingsFile -properties @{ "customALGoFiles" = @{ "filesToInclude" = @( @{ "filter" = $defaultCustomFileName } ) "filesToExclude" = @( @{ "sourceFolder" = ".github/workflows"; "filter" = $excludedWorkflowFileName } ) @@ -369,6 +370,15 @@ Remove-Item -Path (Join-Path (Get-Location) $missingWorkflowFileRelativePath) -F # Check that missing workflow file is NOT present in final repository (Join-Path (Get-Location) $missingWorkflowFileRelativePath) | Should -Not -Exist +# Create a stale snapshot of the template repository settings file in the final repository, +# to simulate a scenario where the final repository has an outdated snapshot of the template repository's settings +Copy-Item -Path $templateRepoSettingsFile -Destination $CustomTemplateRepoSettingsFile -Force +$null = Add-PropertiesToJsonFile -path $CustomTemplateRepoSettingsFile -properties @{ + "customALGoFiles" = @{ + "filesToExclude" = @( @{ "sourceFolder" = ".github/workflows"; "filter" = $missingWorkflowFileName } ) + } +} + # Push CommitAndPush -commitMessage 'Add final repo customizations [skip ci]' @@ -387,6 +397,7 @@ Pull (Join-Path (Get-Location) $CustomTemplateRepoSettingsFile) | Should -Exist (Join-Path (Get-Location) $CustomTemplateProjectSettingsFile) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $CustomTemplateRepoSettingsFile) | Should -Be (Get-ContentLF -Path $templateRepoSettingsFile) # Check that custom workflow file is present (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist @@ -402,9 +413,14 @@ Get-ContentLF -Path (Join-Path (Get-Location) $defaultCustomFileName) | Should - # Check that excluded workflow file is NOT present (in default filesToInclude and template's filesToExclude) (Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Not -Exist -# Check that missing workflow file is present (in default filesToInclude, propagated from PTE template) +# Check that missing workflow file is present (in default filesToInclude, propagated from PTE template). +# This proves the stale snapshot exclusion seeded during final repository setup was replaced. (Join-Path (Get-Location) $missingWorkflowFileRelativePath) | Should -Exist +#endregion + +#region setup final repository customizations for next update run + # Add customALGoFiles settings to the final repository $null = Add-PropertiesToJsonFile -path '.github/AL-Go-Settings.json' -properties @{ "customALGoFiles" = @{ From 409371e5b009601c160d612b2b93dac25f8947ff Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Fri, 24 Jul 2026 14:11:14 +0200 Subject: [PATCH 09/34] Cleanup --- Actions/CheckForUpdates/CheckForUpdates.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index e08613fcb5..fcba5ebde0 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -61,7 +61,6 @@ if ($repoSettings.templateUrl -ne $templateUrl -or $templateSha -eq '') { } $originalTemplateFolder = $null -$templateRepoSettings = $null $templateFolder = DownloadTemplateRepository -token $token -templateUrl $templateUrl -templateSha ([ref]$templateSha) -downloadLatest $downloadLatest $templateFolder = GetSrcFolder -repoType $repoSettings.type -templateUrl $templateUrl -templateFolder $templateFolder Write-Host "Template Folder: $templateFolder" From 84a656e256349c5e7ac8399bf7ff43532542b90c Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Fri, 24 Jul 2026 14:24:47 +0200 Subject: [PATCH 10/34] Cleanup --- .../CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 735bad6bda..f5f6091c3a 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -1172,13 +1172,13 @@ function GetFilesToUpdate { # Send telemetery about customALGoFiles usage if ($settings.customALGoFiles.filesToInclude.Count -gt 0) { - Trace-Information -Message "Usage: Custom AL-Go Files (Include) of repository" + Trace-Information -Message "Usage: Custom AL-Go Files (Include)" } if ($settings.customALGoFiles.filesToExclude.Count -gt 0) { - Trace-Information -Message "Usage: Custom AL-Go Files (Exclude) of repository" + Trace-Information -Message "Usage: Custom AL-Go Files (Exclude)" } if ($settings.customALGoFiles.filesToRemove.Count -gt 0) { - Trace-Information -Message "Usage: Custom AL-Go Files (Remove) of repository" + Trace-Information -Message "Usage: Custom AL-Go Files (Remove)" } # Determine files to include @@ -1235,7 +1235,7 @@ function GetFilesToUpdate { }) # Apply unusedALGoSystemFiles logic - $unusedALGoSystemFiles = @($settings.unusedALGoSystemFiles) + $unusedALGoSystemFiles = $settings.unusedALGoSystemFiles # Exclude unusedALGoSystemFiles from $filesToInclude and add them to $filesToExclude $unusedFilesToExclude = $filesToInclude | Where-Object { $unusedALGoSystemFiles -contains (Split-Path -Path $_.sourceFullPath -Leaf) } From 8682d621dd7183739d5efacee66cd2cbc593eec9 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Fri, 24 Jul 2026 15:32:16 +0200 Subject: [PATCH 11/34] Fix copilot review comments --- .../CheckForUpdates.HelperFunctions.ps1 | 51 ++++++++---- Actions/CheckForUpdates/CheckForUpdates.ps1 | 9 +-- Tests/CheckForUpdates.Action.Test.ps1 | 81 +++++++++++++++---- 3 files changed, 100 insertions(+), 41 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index f5f6091c3a..d5a304ecc1 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -1083,18 +1083,17 @@ function GetDefaultFilesToExclude { <# .SYNOPSIS - Refreshes the on-disk snapshot of a custom template's own repository settings. + Reads settings using the current custom template repository settings without changing the workspace. .DESCRIPTION - Overwrites /.github/AL-Go-TemplateRepoSettings.doNotEdit.json with the current content of - /.github/AL-Go-Settings.json, so that a subsequent ReadSettings call picks up the - custom template's up-to-date settings (customALGoFiles, unusedALGoSystemFiles, etc.) instead of the - last-committed (potentially stale) snapshot. No-op if the template does not have its own settings file. + Temporarily refreshes the custom template repository settings snapshot, reads the merged settings, and restores + the snapshot to its original state. This allows the current template settings to affect the current run while + preserving the workspace state for the normal update comparison. .PARAMETER baseFolder - The base folder of the repository (the target folder where the snapshot file is (over)written). + The base folder of the repository whose settings are read. .PARAMETER templateFolder - The folder where the (custom) template files are located (the source of the fresh settings). + The folder where the custom template files are located. #> -function UpdateCustomTemplateRepoSettingsSnapshot { +function ReadSettingsWithCurrentCustomTemplateRepoSettings { Param( [Parameter(Mandatory=$true)] [string] $baseFolder, @@ -1102,11 +1101,30 @@ function UpdateCustomTemplateRepoSettingsSnapshot { [string] $templateFolder ) - $srcFile = Join-Path $templateFolder (Join-Path '.github' $RepoSettingsFileName) - if (Test-Path -Path $srcFile -PathType Leaf) { - $dstFile = Join-Path $baseFolder (Join-Path '.github' $CustomTemplateRepoSettingsFileName) - Write-Host "Refreshing $CustomTemplateRepoSettingsFileName from the custom template's up-to-date $RepoSettingsFileName" - Get-ContentLF -path $srcFile | Set-ContentLF -path $dstFile + $templateFolderRepoSettingsPath = Join-Path $templateFolder $RepoSettingsFile + + $baseFolderTemplateSettingsPath = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $baseFolderTemplateSettingsBackupPath = $null + + if (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { + $baseFolderTemplateSettingsBackupPath = Join-Path (GetTemporaryPath) ([Guid]::NewGuid().ToString()) + Copy-Item -LiteralPath $baseFolderTemplateSettingsPath -Destination $baseFolderTemplateSettingsBackupPath -Force + } + + try { + if (Test-Path -LiteralPath $templateFolderRepoSettingsPath -PathType Leaf) { + Copy-Item -LiteralPath $templateFolderRepoSettingsPath -Destination $baseFolderTemplateSettingsPath -Force + } + return ReadSettings -baseFolder $baseFolder -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse + } + finally { + if ($baseFolderTemplateSettingsBackupPath) { + Copy-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Destination $baseFolderTemplateSettingsPath -Force + Remove-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Force + } + elseif (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { + Remove-Item -LiteralPath $baseFolderTemplateSettingsPath -Force + } } } @@ -1123,10 +1141,9 @@ function UpdateCustomTemplateRepoSettingsSnapshot { 3. filesToRemove: Files to unconditionally delete from the destination. Built from customALGoFiles.filesToRemove in settings and resolved against template folder, the original template folder (if any), and the destination folder. - Note: when a custom template is in use, the caller is expected to have already refreshed - .github/AL-Go-TemplateRepoSettings.doNotEdit.json (see UpdateCustomTemplateRepoSettingsSnapshot) and - re-read settings before calling this function, so that the template's customALGoFiles/unusedALGoSystemFiles - are already merged into settings. + Note: when a custom template is in use, the caller is expected to call + ReadSettingsWithCurrentCustomTemplateRepoSettings before this function, so that the template's + customALGoFiles/unusedALGoSystemFiles are already merged into settings. The deprecated unusedALGoSystemFiles setting is also applied: matching files are moved from filesToInclude to filesToExclude with a deprecation warning. diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index fcba5ebde0..63ee3532fb 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -115,13 +115,8 @@ if (-not $isDirectALGo) { $baseFolder = $ENV:GITHUB_WORKSPACE if ($originalTemplateFolder) { - # A custom template is in use. Refresh the on-disk snapshot of the custom template's repo settings - # (.github/AL-Go-TemplateRepoSettings.doNotEdit.json) with the up-to-date content from the template - # we just downloaded, and re-read repo settings, so this run resolves customALGoFiles / - # unusedALGoSystemFiles using the current template settings instead of the last-committed - # (potentially stale) snapshot. - UpdateCustomTemplateRepoSettingsSnapshot -baseFolder $baseFolder -templateFolder $templateFolder - $repoSettings = ReadSettings -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse + # Use current custom template settings for this run without changing the workspace before comparison. + $repoSettings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } $projects = @(GetProjectsFromRepository -baseFolder $baseFolder -projectsFromSettings $repoSettings.projects) diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 5b1f8564f5..2af5354c19 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -289,6 +289,7 @@ Describe "CheckForUpdates Action: ApplyWorkflowDefaultInputs Tests" { BeforeAll { $actionName = "CheckForUpdates" $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "..\AL-Go-Helper.ps1" -Resolve) . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") } @@ -2970,42 +2971,88 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } -Describe "UpdateCustomTemplateRepoSettingsSnapshot" { +Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { BeforeAll { $actionName = "CheckForUpdates" $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "..\AL-Go-Helper.ps1" -Resolve) . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") } - It 'Overwrites the destination snapshot file with the current content of the template settings file' { - $templateFolder = Join-Path "TestDrive:" "template" - $baseFolder = Join-Path "TestDrive:" "base" + It 'Uses current template settings and restores an existing snapshot' { + $templateFolder = Join-Path $TestDrive "templateWithCurrentSettings" + $baseFolder = Join-Path $TestDrive "baseWithExistingSnapshot" New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null - $srcFile = Join-Path $templateFolder (Join-Path ".github" $RepoSettingsFileName) - Set-Content -Path $srcFile -Value '{"type":"PTE","customALGoFiles":{"filesToInclude":[]}}' -Encoding UTF8 + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + $templateSettingsContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"current.txt"}]}}' + Set-Content -LiteralPath $templateSettingsFile -Value $templateSettingsContent -Encoding UTF8 - $dstFile = Join-Path $baseFolder (Join-Path ".github" $CustomTemplateRepoSettingsFileName) - Set-Content -Path $dstFile -Value '{"type":"PTE","customALGoFiles":{"filesToInclude":[{"filter":"old.txt"}]}}' -Encoding UTF8 + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $snapshotContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"stale.txt"}]}}' + Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 + $snapshotHash = (Get-FileHash -LiteralPath $snapshotFile).Hash - UpdateCustomTemplateRepoSettingsSnapshot -baseFolder $baseFolder -templateFolder $templateFolder + $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder - Test-Path -Path $dstFile -PathType Leaf | Should -Be $true - (Get-ContentLF -path $dstFile) | Should -Be (Get-ContentLF -path $srcFile) + $settings.customALGoFiles.filesToInclude.Count | Should -Be 1 + $settings.customALGoFiles.filesToInclude[0].filter | Should -Be "current.txt" + (Get-FileHash -LiteralPath $snapshotFile).Hash | Should -Be $snapshotHash + Get-ContentLF -Path $snapshotFile | Should -Be $snapshotContent } - It 'Does not throw and does not create a destination file when the template has no settings file' { - $templateFolder = Join-Path "TestDrive:" "templateWithoutSettings" - $baseFolder = Join-Path "TestDrive:" "baseWithoutSettings" + It 'Removes a temporary snapshot when none existed before reading settings' { + $templateFolder = Join-Path $TestDrive "templateWithoutSnapshot" + $baseFolder = Join-Path $TestDrive "baseWithoutSnapshot" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + Set-Content -LiteralPath $templateSettingsFile -Value '{"customALGoFiles":{"filesToInclude":[{"filter":"current.txt"}]}}' -Encoding UTF8 + + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + Test-Path -LiteralPath $snapshotFile | Should -Be $false + + $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder + + $settings.customALGoFiles.filesToInclude[0].filter | Should -Be "current.txt" + Test-Path -LiteralPath $snapshotFile | Should -Be $false + } + + It 'Does not change an existing snapshot when the template has no settings file' { + $templateFolder = Join-Path $TestDrive "templateWithoutSettings" + $baseFolder = Join-Path $TestDrive "baseWithUnchangedSnapshot" New-Item -ItemType Directory -Path $templateFolder -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null - $dstFile = Join-Path $baseFolder (Join-Path ".github" $CustomTemplateRepoSettingsFileName) + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $snapshotContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"existing.txt"}]}}' + Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 + $snapshotHash = (Get-FileHash -LiteralPath $snapshotFile).Hash + + $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder + + $settings.customALGoFiles.filesToInclude[0].filter | Should -Be "existing.txt" + (Get-FileHash -LiteralPath $snapshotFile).Hash | Should -Be $snapshotHash + } + + It 'Restores an existing snapshot when reading refreshed settings fails' { + $templateFolder = Join-Path $TestDrive "templateWithInvalidSettings" + $baseFolder = Join-Path $TestDrive "baseWithSnapshotAfterFailure" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + Set-Content -LiteralPath $templateSettingsFile -Value '{ invalid json' -Encoding UTF8 + + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $snapshotContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"stale.txt"}]}}' + Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 - { UpdateCustomTemplateRepoSettingsSnapshot -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Not -Throw + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw - Test-Path -Path $dstFile -PathType Leaf | Should -Be $false + Get-ContentLF -Path $snapshotFile | Should -Be $snapshotContent } } From 051d08aaf64914da933e3020fa1fdb7212009ebe Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Wed, 29 Jul 2026 15:49:32 +0200 Subject: [PATCH 12/34] Remove support for fileToRemove --- Actions/.Modules/ReadSettings.psm1 | 1 - Actions/.Modules/settings.schema.json | 29 - .../CheckForUpdates.HelperFunctions.ps1 | 2439 ++++++++--------- Actions/CheckForUpdates/CheckForUpdates.ps1 | 6 +- RELEASENOTES.md | 5 +- Scenarios/CustomizingALGoForGitHub.md | 78 +- Scenarios/settings.md | 2 +- Tests/CheckForUpdates.Action.Test.ps1 | 544 +--- e2eTests/scenarios/CustomTemplate/runtest.ps1 | 11 - 9 files changed, 1210 insertions(+), 1905 deletions(-) diff --git a/Actions/.Modules/ReadSettings.psm1 b/Actions/.Modules/ReadSettings.psm1 index d198fb27ae..99e47fd1f0 100644 --- a/Actions/.Modules/ReadSettings.psm1 +++ b/Actions/.Modules/ReadSettings.psm1 @@ -260,7 +260,6 @@ function GetDefaultSettings "customALGoFiles" = [ordered]@{ "filesToInclude" = @() "filesToExclude" = @() - "filesToRemove" = @() } "postponeProjectInBuildOrder" = $false } diff --git a/Actions/.Modules/settings.schema.json b/Actions/.Modules/settings.schema.json index 650d183d70..94b3ac14f0 100644 --- a/Actions/.Modules/settings.schema.json +++ b/Actions/.Modules/settings.schema.json @@ -783,35 +783,6 @@ } } } - }, - "filesToRemove": { - "description": "An array of file specifications to unconditionally remove from the repository during 'Update AL-Go System Files', regardless of whether the files exist in the template. Useful for cleaning up files that have been removed from the template but may still exist in repositories.", - "type": "array", - "items": { - "type": "object", - "properties": { - "sourceFolder": { - "type": "string", - "description": "The source folder from which to remove files, relative to the template repository root." - }, - "filter": { - "type": "string", - "description": "A filter string to select which files to remove. It can contain '*' and '?' wildcards." - }, - "destinationFolder": { - "type": "string", - "description": "The destination folder where the files should be removed, relative to the repository root." - }, - "destinationName": { - "type": "string", - "description": "The filename of the file at the destination. If specified, overrides the filter when looking up the file to remove. Should be used together with a filter that matches a single file." - }, - "perProject": { - "type": "boolean", - "description": "Indicates whether the removal should be applied per project. In that case, destinationFolder is relative to each project folder." - } - } - } } } }, diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index d5a304ecc1..9aca08ea4f 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -1,1276 +1,1163 @@ -Import-Module (Join-Path $PSScriptRoot '../.Modules/ReadSettings.psm1') -DisableNameChecking -Import-Module (Join-Path $PSScriptRoot '../.Modules/DebugLogHelper.psm1') -DisableNameChecking - -<# -.SYNOPSIS -Downloads a template repository and returns the path to the downloaded folder -.PARAMETER token -The GitHub token / PAT to use for authentication (if the template repository is private/internal) -.PARAMETER templateUrl -The URL to the template repository -.PARAMETER templateSha -The SHA of the template repository (returned by reference if downloadLatest is true) -.PARAMETER downloadLatest -If true, the latest SHA of the template repository will be downloaded -#> -function DownloadTemplateRepository { - Param( - [string] $token, - [string] $templateUrl, - [ref] $templateSha, - [bool] $downloadLatest - ) - - $templateRepositoryUrl = $templateUrl.Split('@')[0] - $templateRepository = $templateRepositoryUrl.Split('/')[-2..-1] -join '/' - - # Use Authenticated API request if possible to avoid the 60 API calls per hour limit - OutputDebug -message "Getting template repository ($templateRepository) with GITHUB_TOKEN" - $headers = GetHeaders -token $env:GITHUB_TOKEN -repository $templateRepository - try { - $response = Invoke-WebRequest -UseBasicParsing -Headers $headers -Method Head -Uri $templateRepositoryUrl - OutputDebug -message ($response | Format-List | Out-String) - } - catch { - # Ignore error - OutputDebug -message "Error getting template repository with GITHUB_TOKEN:" - OutputDebug -message $_ - $response = $null - } - if (-not $response -or $response.StatusCode -ne 200) { - # GITHUB_TOKEN doesn't have access to template repository, must be private/internal - # Get token with read permissions for the template repository - # NOTE that the GitHub app needs to be installed in the template repository for this to work - $headers = GetHeaders -token $token -repository $templateRepository - } - - # Construct API URL - $apiUrl = $templateUrl.Split('@')[0] -replace "^(https:\/\/github\.com\/)(.*)$", "$ENV:GITHUB_API_URL/repos/`$2" - - Write-Host "TemplateUrl: $templateUrl" - Write-Host "TemplateSha: $($templateSha.Value)" - Write-Host "DownloadLatest: $downloadLatest" - - if ($downloadLatest) { - # Get latest commit SHA from the template repository - $templateSha.Value = GetLatestTemplateSha -headers $headers -apiUrl $apiUrl -templateUrl $templateUrl - Write-Host "Latest SHA for $($templateUrl): $($templateSha.Value)" - } - $archiveUrl = "$apiUrl/zipball/$($templateSha.Value)" - Write-Host "Using ArchiveUrl: $archiveUrl" - - # Download template repository - $tempName = Join-Path (GetTemporaryPath) ([Guid]::NewGuid().ToString()) - InvokeWebRequest -Headers $headers -Uri $archiveUrl -OutFile "$tempName.zip" - Expand-7zipArchive -Path "$tempName.zip" -DestinationPath $tempName - Remove-Item -Path "$tempName.zip" - - return $tempName -} - -function GetLatestTemplateSha { - Param( - [hashtable] $headers, - [string] $apiUrl, - [string] $templateUrl - ) - - $branch = $templateUrl.Split('@')[1] - Write-Host "Get latest SHA for $templateUrl" - try { - $branchInfo = (InvokeWebRequest -Headers $headers -Uri "$apiUrl/branches/$branch").Content | ConvertFrom-Json - } catch { - throw "Failed to update AL-Go System Files. Could not get the latest SHA from template ($templateUrl). (Error was $($_.Exception.Message))" - } - return $branchInfo.commit.sha -} - -function ModifyCICDWorkflow { - Param( - [Yaml] $yaml, - [hashtable] $repoSettings - ) - - # The CICD workflow can have a RepoSetting called CICDPushBranches, which will be used to set the branches for the workflow - # Setting the CICDSchedule will disable the push trigger for the CI/CD workflow (unless CICDPushBranches is set) - if ($repoSettings.Keys -contains 'CICDPushBranches') { - $CICDPushBranches = $repoSettings.CICDPushBranches - } - elseif ($repoSettings.Keys -contains $workflowScheduleKey) { - $CICDPushBranches = '' - } - else { - $CICDPushBranches = $defaultCICDPushBranches - } - # update the branches: line with the new branches - if ($CICDPushBranches) { - $yaml.Replace('on:/push:/branches:', "branches: [ '$($CICDPushBranches -join "', '")' ]") - } - else { - $yaml.Replace('on:/push:',@()) - } -} - -function ModifyPullRequestHandlerWorkflow { - Param( - [Yaml] $yaml, - [hashtable] $repoSettings - ) - # The PullRequestHandler workflow can have a RepoSetting called pullRequestTrigger which specifies the trigger to use for Pull Requests - $triggerSection = $yaml.Get('on:/pull') - $triggerSection.content = "$($repoSettings.pullRequestTrigger):" - $yaml.Replace('on:/pull', $triggerSection.Content) - - # The PullRequestHandler workflow can have a RepoSetting called CICDPullRequestBranches, which will be used to set the branches for the workflow - if ($repoSettings.Keys -contains 'CICDPullRequestBranches') { - $CICDPullRequestBranches = $repoSettings.CICDPullRequestBranches - } - else { - $CICDPullRequestBranches = $defaultCICDPullRequestBranches - } - - # update the branches: line with the new branches - $yaml.Replace("on:/$($repoSettings.pullRequestTrigger):/branches:", "branches: [ '$($CICDPullRequestBranches -join "', '")' ]") -} - -function ModifyRunsOnAndShell { - Param( - [Yaml] $yaml, - [hashtable] $repoSettings - ) - - # The default for runs-on is windows-latest and the default for shell is powershell - # The default for GitHubRunner/GitHubRunnerShell is runs-on/shell (unless Ubuntu-latest are selected here, as build jobs cannot run on Ubuntu) - # We do not change runs-on in Update AL-Go System Files and Pull Request Handler workflows - # These workflows will always run on windows-latest (or maybe Ubuntu-latest later) and not follow settings - # Reasons: - # - Update AL-Go System files is needed for changing runs-on - by having non-functioning runners, you might dead-lock yourself - # - Pull Request Handler workflow for security reasons - if ($repoSettings."runs-on" -ne "windows-latest") { - Write-Host "Setting runs-on to [ $($repoSettings."runs-on") ]" - $yaml.ReplaceAll('runs-on: [ windows-latest ]', "runs-on: [ $($repoSettings."runs-on") ]") - } - if ($repoSettings.shell -ne "powershell" -and $repoSettings.shell -ne "pwsh") { - throw "The shell can only be set to powershell or pwsh" - } - if ($repoSettings."runs-on" -eq "ubuntu-latest" -and $repoSettings.shell -eq "powershell") { - throw "The shell cannot be set to powershell when runs-on is ubuntu-latest. Use pwsh instead." - } - Write-Host "Setting shell to $($repoSettings.shell)" - $yaml.ReplaceAll('shell: powershell', "shell: $($repoSettings.shell)") -} - -function ModifyBuildWorkflows { - Param( - [Yaml] $yaml, - [int] $depth, - [bool] $includeBuildPP - ) - - $yaml.Replace('env:/workflowDepth:',"workflowDepth: $depth") - $build = $yaml.Get('jobs:/Build:/') - $buildPP = $yaml.Get('jobs:/BuildPP:/') - $deliver = $yaml.Get('jobs:/Deliver:/') - $deploy = $yaml.Get('jobs:/Deploy:/') - $deployALDoc = $yaml.Get('jobs:/DeployALDoc:/') - $codeAnalysisUpload = $yaml.Get('jobs:/CodeAnalysisUpload:/') - $postProcess = $yaml.Get('jobs:/PostProcess:/') - if (!$build) { - throw "No build job found in the workflow" - } - - # Duplicate the build job for each dependency depth - $newBuild = @() - for($index = 0; $index -lt $depth; $index++) { - # All build job needs to have a dependency on the Initialization job - $needs = @('Initialization') - if ($index -eq 0) { - # First build job needs to have a dependency on the Initialization job only - # Example (depth 1): - # needs: [ Initialization ] - # if: (!failure()) && (!cancelled()) && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 - $if = "if: (!failure()) && (!cancelled()) && fromJson(needs.Initialization.outputs.buildOrderJson)[$index].projectsCount > 0" - } - else { - # Subsequent build jobs needs to have a dependency on all previous build jobs - # Example (depth 2): - # needs: [ Initialization, Build1 ] - # if: (!failure()) && (!cancelled()) && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 - # Another example (depth 3): - # needs: [ Initialization, Build2, Build1 ] - # if: (!failure()) && (!cancelled()) && (needs.Build2.result == 'success' || needs.Build2.result == 'skipped') && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 - $newBuild += @('') - $ifpart = "" - $index..1 | ForEach-Object { - $needs += @("Build$_") - $ifpart += " && (needs.Build$_.result == 'success' || needs.Build$_.result == 'skipped')" - } - $if = "if: (!failure()) && (!cancelled())$ifpart && fromJson(needs.Initialization.outputs.buildOrderJson)[$index].projectsCount > 0" - } - - # Replace the if:, the needs: and the strategy/matrix/project: in the build job with the correct values - $build.Replace('if:', $if) - $build.Replace('needs:', "needs: [ $($needs -join ', ') ]") - $build.Replace('strategy:/matrix:/include:',"include: `${{ fromJson(needs.Initialization.outputs.buildOrderJson)[$index].buildDimensions }}") - - # Last build job is called build, all other build jobs are called build1, build2, etc. - if ($depth -eq ($index + 1)) { - $newBuild += @("Build:") - } - else { - $newBuild += @("Build$($index + 1):") - } - - # Add the content of the calculated build job to the new build job list with an indentation of 2 spaces - $build.content | ForEach-Object { $newBuild += @(" $_") } - } - - # Replace the entire build: job with the new build job list - $yaml.Replace('jobs:/Build:', $newBuild) - - if (!$includeBuildPP -and $buildPP) { - # Remove the BuildPP job from the workflow - [int]$start = 0 - [int]$count = 0 - if ($yaml.Find('jobs:/BuildPP:', [ref] $start, [ref] $count)) { - $yaml.Remove($start, $count+1) - } - } - - $needs += @("Build") - $ifpart += " && (needs.Build.result == 'success' || needs.Build.result == 'skipped')" - if ($includeBuildPP -and $buildPP) { - $needs += @("BuildPP") - $ifpart += " && (needs.BuildPP.result == 'success' || needs.BuildPP.result == 'skipped')" - } - - $postProcessNeeds = $needs - # Modify Deliver and Deploy steps depending on build jobs - if ($deploy) { - $deploy.Replace('needs:', "needs: [ $($needs -join ', ') ]") - $deploy.Replace('if:', "if: (!cancelled())$ifpart && needs.Initialization.outputs.environmentCount > 0") - $yaml.Replace('jobs:/Deploy:/', $deploy.content) - $postProcessNeeds += @('Deploy') - } - if ($deliver) { - $deliver.Replace('needs:', "needs: [ $($needs -join ', ') ]") - $deliver.Replace('if:', "if: (!cancelled())$ifpart && needs.Initialization.outputs.deliveryTargetsJson != '[]'") - $yaml.Replace('jobs:/Deliver:/', $deliver.content) - $postProcessNeeds += @('Deliver') - } - if ($deployALDoc) { - $postProcessNeeds += @('DeployALDoc') - } - if ($codeAnalysisUpload) { - $postProcessNeeds += @('CodeAnalysisUpload') - } - if ($postProcess) { - $postProcess.Replace('needs:', "needs: [ $($postProcessNeeds -join ', ') ]") - $yaml.Replace('jobs:/PostProcess:/', $postProcess.content) - } -} - -function ModifyUpdateALGoSystemFiles { - Param( - [Yaml] $yaml, - [hashtable] $repoSettings - ) - - if($repoSettings.Keys -notcontains 'UpdateALGoSystemFilesEnvironment') { - # If UpdateALGoSystemFilesEnvironment is not set, we don't need to do anything - return - } - - $updateALGoSystemFilesEnvironment = $repoSettings.UpdateALGoSystemFilesEnvironment - Write-Host "Setting 'Update AL-Go System Files' environment to $updateALGoSystemFilesEnvironment" - - # Add or replace the environment: section in the UpdateALGoSystemFiles job - $updateALGoSystemFilesJob = $yaml.Get('jobs:/UpdateALGoSystemFiles:/') - - if(-not $updateALGoSystemFilesJob) { - # Defensively check that the UpdateALGoSystemFiles job exists - throw "No UpdateALGoSystemFiles job found in the workflow" - } - - $environmentSection = $updateALGoSystemFilesJob.Get('environment:') - if($environmentSection) { - # If the environment: section already exists, replace it with the new environment - $updateALGoSystemFilesJob.Replace($environmentSection, "environment: $updateALGoSystemFilesEnvironment") - } - else { - # If the environment: section does not exist, add it - $updateALGoSystemFilesJob.Insert(1, "environment: $updateALGoSystemFilesEnvironment") - } - - $yaml.Replace('jobs:/UpdateALGoSystemFiles:/', $updateALGoSystemFilesJob.content) -} - -function ApplyWorkflowDefaultInputs { - Param( - [Yaml] $yaml, - [hashtable] $repoSettings, - [string] $workflowName - ) - - # Check if workflow_dispatch inputs exist - $workflowDispatch = $yaml.Get('on:/workflow_dispatch:/') - if (-not $workflowDispatch) { - # No workflow_dispatch section, nothing to do - return - } - - $inputs = $workflowDispatch.Get('inputs:/') - if (-not $inputs) { - # No inputs section, nothing to do - return - } - - if ($repoSettings.workflowDefaultInputs.Count -eq 0) { - # No defaults for this workflow - return - } - - # Get workflow_call inputs - $workflowCallInputs = $yaml.Get('on:/workflow_call:/inputs:/') - - # Apply defaults to matching inputs - $workflowDispatchInputsModified = $false - $workflowCallInputsModified = $false - - foreach ($defaultInput in $repoSettings.workflowDefaultInputs) { - # Apply to workflow_dispatch inputs and only update workflow_call if dispatch was modified - if (ApplyWorkflowDefaultInput -workflowName $workflowName -inputs $inputs -defaultInput $defaultInput) { - $workflowDispatchInputsModified = $true - - # Only apply to workflow_call inputs if the workflow_dispatch input was modified - if ($workflowCallInputs) { - if (ApplyWorkflowDefaultInput -workflowName $workflowName -inputs $workflowCallInputs -defaultInput $defaultInput) { - $workflowCallInputsModified = $true - } - } - } - } - - # Update the workflow_dispatch section if modified - if ($workflowDispatchInputsModified) { - $workflowDispatch.Replace('inputs:/', $inputs.content) - $yaml.Replace('on:/workflow_dispatch:/', $workflowDispatch.content) - } - - # Update workflow_call inputs if modified - if ($workflowCallInputsModified) { - $yaml.Replace('on:/workflow_call:/inputs:/', $workflowCallInputs.content) - } -} - -function ApplyWorkflowDefaultInput { - Param( - [string] $workflowName, - [Yaml] $inputs, - [hashtable] $defaultInput - ) - - $inputName = $defaultInput.name - $defaultValue = $defaultInput.value - - # Check if this input exists in the inputs collection - $inputSection = $inputs.Get("$($inputName):/") - if (-not $inputSection) { - # Input is not present in the workflow - return $false - } - - # Get the input type from the YAML if specified - $inputType = $null - $typeStart = 0 - $typeCount = 0 - if ($inputSection.Find('type:', [ref] $typeStart, [ref] $typeCount)) { - $typeLine = $inputSection.content[$typeStart].Trim() - if ($typeLine -match 'type:\s*(.+)') { - $inputType = $matches[1].Trim() - } - } - - # Validate that the value type matches the input type - $validationError = $null - if ($inputType) { - switch ($inputType) { - 'boolean' { - if ($defaultValue -isnot [bool]) { - $validationError = "Workflow '$workflowName', input '$inputName': Expected boolean value, but got $($defaultValue.GetType().Name). Please use `$true or `$false." - } - } - 'number' { - if ($defaultValue -isnot [int] -and $defaultValue -isnot [long] -and $defaultValue -isnot [double]) { - $validationError = "Workflow '$workflowName', input '$inputName': Expected number value, but got $($defaultValue.GetType().Name)." - } - } - 'string' { - if ($defaultValue -isnot [string]) { - $validationError = "Workflow '$workflowName', input '$inputName': Expected string value, but got $($defaultValue.GetType().Name)." - } - } - 'choice' { - # Choice inputs accept strings and must match one of the available options (case-sensitive) - if ($defaultValue -isnot [string]) { - $validationError = "Workflow '$workflowName', input '$inputName': Expected string value for choice input, but got $($defaultValue.GetType().Name)." - } - else { - # Validate that the value is one of the available options - $optionsStart = 0 - $optionsCount = 0 - if ($inputSection.Find('options:', [ref] $optionsStart, [ref] $optionsCount)) { - $availableOptions = @() - # Parse the options from the YAML (they are indented list items starting with "- ") - for ($i = $optionsStart + 1; $i -lt ($optionsStart + $optionsCount); $i++) { - $optionLine = $inputSection.content[$i].Trim() - if ($optionLine -match '^-\s*(.+)$') { - $availableOptions += $matches[1].Trim() - } - } - - if ($availableOptions.Count -gt 0 -and $availableOptions -cnotcontains $defaultValue) { - $validationError = "Workflow '$workflowName', input '$inputName': Value '$defaultValue' is not a valid choice (case-sensitive match required). Available options: $($availableOptions -join ', ')." - } - } - } - } - } - } - else { - # If no type is specified in the workflow, it defaults to string - if ($defaultValue -isnot [string]) { - OutputWarning "Workflow '$workflowName', input '$inputName': No type specified in workflow (defaults to string), but configured value is $($defaultValue.GetType().Name). This may cause issues." - } - } - - if ($validationError) { - throw $validationError - } - - # Convert the default value to the appropriate YAML format - $yamlValue = $defaultValue - if ($defaultValue -is [bool]) { - $yamlValue = $defaultValue.ToString().ToLower() - } - elseif ($defaultValue -is [string]) { - # Quote strings and escape single quotes per YAML spec - $escapedValue = $defaultValue.Replace("'", "''") - $yamlValue = "'$escapedValue'" - } - - # Find and replace the default: line in the input section - $start = 0 - $count = 0 - - if ($inputSection.Find('default:', [ref] $start, [ref] $count)) { - # Replace existing default value - $inputSection.Replace('default:', "default: $yamlValue") - } - else { - # Add default value - find the best place to insert it - # Insert after type, required, or description (whichever comes last) - $insertAfter = -1 - $typeLine = 0 - $typeCount = 0 - $requiredLine = 0 - $requiredCount = 0 - $descLine = 0 - $descCount = 0 - - if ($inputSection.Find('type:', [ref] $typeLine, [ref] $typeCount)) { - $insertAfter = $typeLine + $typeCount - } - if ($inputSection.Find('required:', [ref] $requiredLine, [ref] $requiredCount)) { - if (($requiredLine + $requiredCount) -gt $insertAfter) { - $insertAfter = $requiredLine + $requiredCount - } - } - if ($inputSection.Find('description:', [ref] $descLine, [ref] $descCount)) { - if (($descLine + $descCount) -gt $insertAfter) { - $insertAfter = $descLine + $descCount - } - } - - if ($insertAfter -eq -1) { - # No other properties, insert at position 1 (after the input name) - $insertAfter = 1 - } - - $inputSection.Insert($insertAfter, "default: $yamlValue") - } - - # Update the inputs collection with the modified input section - $inputs.Replace("$($inputName):/", $inputSection.content) - - return $true -} - -function GetWorkflowContentWithChangesFromSettings { - Param( - [string] $srcFile, - [hashtable] $repoSettings, - [int] $depth - ) - - $baseName = [System.IO.Path]::GetFileNameWithoutExtension($srcFile) - $yaml = [Yaml]::Load($srcFile) - $yamlName = $yaml.get('name:') - if ($yamlName) { - $workflowName = $yamlName.content.SubString('name:'.Length).Trim().Trim('''"').Trim() - } - else { - $workflowName = $baseName - } - - $workflowScheduleKey = "WorkflowSchedule" - $workflowConcurrencyKey = "WorkflowConcurrency" - foreach($key in @($workflowScheduleKey,$workflowConcurrencyKey)) { - if ($repoSettings.Keys -contains $key -and ($repoSettings."$key")) { - throw "The $key setting is not allowed in the global repository settings. Please use the workflow specific settings file or conditional settings." - } - } - - # Re-read settings and this time include workflow specific settings - $repoSettings = ReadSettings -buildMode '' -project '' -workflowName $workflowName -userName '' -branchName '' | ConvertTo-HashTable -recurse - - # Old Schedule key is deprecated, but still supported - $oldWorkflowScheduleKey = "$($baseName)Schedule" - if ($repoSettings.Keys -contains $oldWorkflowScheduleKey) { - # DEPRECATION: REPLACE WITH ERROR AFTER October 1st 2025 ---> - if ($repoSettings.Keys -contains $workflowScheduleKey) { - OutputWarning "Both $oldWorkflowScheduleKey and $workflowScheduleKey are defined in the settings file. $oldWorkflowScheduleKey will be ignored. This warning will become an error in the future" - } - else { - Trace-DeprecationWarning -Message "$oldWorkflowScheduleKey is deprecated" -DeprecationTag "_workflow_Schedule" -WillBecomeError - # Convert the old Schedule setting to the new WorkflowSchedule setting - $repoSettings."$workflowScheduleKey" = @{ "cron" = $repoSettings."$oldWorkflowScheduleKey" } - } - # <--- REPLACE WITH ERROR AFTER October 1st 2025 - } - - # Any workflow (except for the PullRequestHandler and reusable workflows (_*)) can have concurrency and schedule defined - if ($baseName -ne "PullRequestHandler" -and $baseName -notlike '_*') { - # Add Schedule and Concurrency settings to the workflow - if ($repoSettings.Keys -contains $workflowScheduleKey) { - if ($repoSettings."$workflowScheduleKey" -isnot [hashtable] -or $repoSettings."$workflowScheduleKey".Keys -notcontains 'cron' -or $repoSettings."$workflowScheduleKey".cron -isnot [string]) { - throw "The $workflowScheduleKey setting must be a structure containing a cron property" - } - # Replace or add the schedule part under the on: key - $yaml.ReplaceOrAdd('on:/', 'schedule:', @("- cron: '$($repoSettings."$workflowScheduleKey".cron)'")) - } - if ($repoSettings.Keys -contains $workflowConcurrencyKey) { - # Replace or add the concurrency part - $yaml.ReplaceOrAdd('', 'concurrency:', $repoSettings."$workflowConcurrencyKey") - } - } - - if ($baseName -eq "CICD") { - ModifyCICDWorkflow -yaml $yaml -repoSettings $repoSettings - } - - if ($baseName -eq "PullRequestHandler") { - ModifyPullRequestHandlerWorkflow -yaml $yaml -repoSettings $repoSettings - } - - $criticalWorkflows = @('UpdateGitHubGoSystemFiles', 'Troubleshooting') - $allowedRunners = @('windows-latest', 'ubuntu-latest') - $modifyRunsOnAndShell = $true - - # Critical workflows may only run on allowed runners (must always be able to run) - if($criticalWorkflows -contains $baseName) { - if($allowedRunners -notcontains $repoSettings."runs-on") { - $modifyRunsOnAndShell = $false - } - } - - if ($modifyRunsOnAndShell) { - ModifyRunsOnAndShell -yaml $yaml -repoSettings $repoSettings - } - - # PullRequestHandler, CICD, Current, NextMinor and NextMajor workflows all include a build step. - # If the dependency depth is higher than 1, we need to add multiple dependent build jobs to the workflow - if ($baseName -eq 'PullRequestHandler' -or $baseName -eq 'CICD' -or $baseName -eq 'Current' -or $baseName -eq 'NextMinor' -or $baseName -eq 'NextMajor') { - $includeBuildPP = $repoSettings.type -eq 'PTE' -and $repoSettings.powerPlatformSolutionFolder -ne '' - ModifyBuildWorkflows -yaml $yaml -depth $depth -includeBuildPP $includeBuildPP - } - - if($baseName -eq 'UpdateGitHubGoSystemFiles') { - ModifyUpdateALGoSystemFiles -yaml $yaml -repoSettings $repoSettings - } - - # Apply workflow input defaults from settings - if ($repoSettings.Keys -contains 'workflowDefaultInputs') { - ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName $workflowName - } - - # combine all the yaml file lines into a single string with LF line endings - $yaml.content -join "`n" -} - -# Using direct AL-Go repo, we need to change the owner to the templateOwner, the repo names to AL-Go and AL-Go/Actions and the branch to templateBranch - -function ReplaceOwnerRepoAndBranch { - Param( - [ref] $srcContent, - [string] $templateOwner, - [string] $templateBranch - ) - - $lines = $srcContent.Value.Split("`n") - - # The Original Owner and Repo in the AL-Go repository are microsoft/AL-Go-Actions, microsoft/AL-Go-PTE and microsoft/AL-Go-AppSource - $originalOwnerAndRepo = @{ - "actionsRepo" = "microsoft/AL-Go-Actions" - "perTenantExtensionRepo" = "microsoft/AL-Go-PTE" - "appSourceAppRepo" = "microsoft/AL-Go-AppSource" - } - # Original branch is always main - $originalBranch = "main" - - # Modify the file to use repository names based on whether or not we are using the direct AL-Go repo - $templateRepos = @{ - "actionsRepo" = "AL-Go/Actions" - "perTenantExtensionRepo" = "AL-Go" - "appSourceAppRepo" = "AL-Go" - } - - # Replace URL's to actions repository first - $regex = "^(.*)https:\/\/raw\.githubusercontent\.com\/microsoft\/AL-Go-Actions\/$originalBranch(.*)$" - $replace = "`${1}https://raw.githubusercontent.com/$($templateOwner)/AL-Go/$($templateBranch)/Actions`${2}" - $lines = $lines | ForEach-Object { $_ -replace $regex, $replace } - - # Replace the owner and repo names in the workflow - "actionsRepo","perTenantExtensionRepo","appSourceAppRepo" | ForEach-Object { - $regex = "^(.*)$($originalOwnerAndRepo."$_")(.*)$originalBranch(.*)$" - $replace = "`${1}$($templateOwner)/$($templateRepos."$_")`${2}$($templateBranch)`${3}" - $lines = $lines | ForEach-Object { $_ -replace $regex, $replace } - } - $srcContent.Value = $lines -join "`n" -} - -function IsDirectALGo { - param ( - [string] $templateUrl - ) - $directALGo = $templateUrl -like 'https://github.com/*/AL-Go@*' - if ($directALGo) { - if ($templateUrl -like 'https://github.com/microsoft/AL-Go@*' -and -not ($templateUrl -like 'https://github.com/microsoft/AL-Go@*/*')) { - throw "You cannot use microsoft/AL-Go as a template repository. Please use microsoft/AL-Go-PTE, microsoft/AL-Go-AppSource or a fork of AL-Go instead." - } - } - return $directALGo -} - -function GetSrcFolder { - Param( - [string] $repoType, - [string] $templateUrl, - [string] $templateFolder, - [string] $srcPath = '' - ) - - if (!$templateUrl) { - return '' - } - if (IsDirectALGo -templateUrl $templateUrl) { - switch ($repoType) { - "PTE" { - $typePath = "Per Tenant Extension" - } - "AppSource App" { - $typePath = "AppSource App" - } - default { - throw "Unknown repository type" - } - } - $path = Join-Path $templateFolder "*/Templates/$typePath/.github/workflows" - } - else { - $path = Join-Path $templateFolder "*/.github/workflows" - } - # Due to this PowerShell bug: https://github.com/PowerShell/PowerShell/issues/6473#issuecomment-375930843 - # We need to resolve the path of a non-hidden folder (.github/workflows) - # and then get the parent folder of the parent folder of that path - $path = Resolve-Path -Path $path -ErrorAction SilentlyContinue - if (!$path) { - throw "No workflows found in the template repository" - } - $path = Join-Path -Path (Split-Path -Path (Split-Path -Path $path -Parent) -Parent) -ChildPath $srcPath - return $path -} - -function GetModifiedSettingsContent { - Param( - [string] $srcSettingsFile, - [string] $dstSettingsFile - ) - - $srcSettings = Get-ContentLF -Path $srcSettingsFile | ConvertFrom-Json - - $dstSettings = $null - if(Test-Path -Path $dstSettingsFile -PathType Leaf) { - $dstSettings = Get-ContentLF -Path $dstSettingsFile | ConvertFrom-Json - } - - if(!$dstSettings) { - # If the destination settings file does not exist or it's empty, create an new settings object with default values from the source settings (which includes the $schema property already) - $dstSettings = $srcSettings - } - else { - # Change the $schema property to be the same as the source settings file (add it if it doesn't exist) - $schemaKey = '$schema' - if ($srcSettings.PSObject.Properties.Name -eq $schemaKey) { - $schemaValue = $srcSettings."$schemaKey" - - $dstSettings | Add-Member -MemberType NoteProperty -Name "$schemaKey" -Value $schemaValue -Force - - # Make sure the $schema property is the first property in the object - # PS5-safe: explicitly list properties instead of using wildcard to avoid literal '*' being added - $otherProperties = @($dstSettings.PSObject.Properties.Name | Where-Object { $_ -ne $schemaKey }) - $selectProperties = @($schemaKey) + $otherProperties - $dstSettings = $dstSettings | Select-Object -Property $selectProperties - } - } - - return $dstSettings | ConvertTo-JsonLF -} - -function UpdateSettingsFile { - Param( - [string] $settingsFile, - [hashtable] $updateSettings - ) - - $modified = $false - # Update Repo Settings file with the template URL - if (Test-Path $settingsFile) { - $settings = Get-Content $settingsFile -Encoding UTF8 | ConvertFrom-Json - } - else { - $settings = [PSCustomObject]@{} - $modified = $true - } - foreach($key in $updateSettings.Keys) { - if ($settings.PSObject.Properties.Name -eq $key) { - if ($settings."$key" -ne $updateSettings."$key") { - $settings."$key" = $updateSettings."$key" - $modified = $true - } - } - else { - # Add the property if it doesn't exist - $settings | Add-Member -MemberType NoteProperty -Name "$key" -Value $updateSettings."$key" - $modified = $true - } - } - if ($modified) { - # Save the file with LF line endings and UTF8 encoding - $settings | Set-JsonContentLF -path $settingsFile - } - return $modified -} - -<# -.SYNOPSIS -Resolves file paths based on the provided source folder, destination folder, and file specifications. - -.DESCRIPTION -This function takes a source folder, an optional original source folder, a destination folder, and an array of file specifications. It resolves the full paths for each specified file, considering their origin (template or custom template), type, and whether they are per-project files. -The function returns an array of hashtables containing the resolved source and destination file paths. -The function is used to determine which files need to be copied from the template repository to the target repository during the AL-Go update process. -sourceFolder: The base folder of the template used to resolve the source file paths. -originalSourceFolder: The base folder of the original template used to check for original files (can be $null). This is in the case of custom templates, where if the file exists in the original template, it should be used instead of the custom template file. -destinationFolder: The base folder used to construct the destination file paths. This is typically the root folder of the target repository. - -.PARAMETER sourceFolder -The base folder where the source files are located. - -.PARAMETER originalSourceFolder -The original source folder to check for original files (can be $null). All files of origin 'custom template' are skipped if this parameter is $null. - -.PARAMETER destinationFolder -The base folder used to construct the destination file paths. - -.PARAMETER files -An array of hashtables specifying the files to resolve. Each hashtable can contain the following keys: -- sourceFolder: The subfolder within the source folder to search for files (default is current folder). -- filter: The file filter to apply when searching for files (default is all files). -- origin: The origin of the files, either 'template' or 'custom template' (default is 'template'). -- type: The type of the files (default is empty). -- destinationFolder: The subfolder within the destination folder where the files should be placed (default is the same as sourceFolder). -- perProject: A boolean indicating whether the files are per project (default is false). - -.PARAMETER projects -An array of project names used when resolving per-project file paths. - -.OUTPUTS -An array of hashtables, each containing: -- sourceFullPath: The full path to the source file. -- originalSourceFullPath: The full path to the original source file (if found, otherwise $null). -- type: The type of the file. -- destinationFullPath: The full path to the destination file. -#> -function ResolveFilePaths { - Param( - [Parameter(Mandatory=$true)] - [string] $sourceFolder, - [string] $originalSourceFolder = $null, - [Parameter(Mandatory=$true)] - [string] $destinationFolder, - [array] $files = @(), - [string[]] $projects = @() - ) - - if(-not $files) { - return @() - } - - $sourceFolder = Join-Path $sourceFolder '' # Ensure source folder has a trailing slash for correct path resolution - - $fullFilePaths = @() - foreach($file in $files) { - if($file.Keys -notcontains 'sourceFolder') { - $file.sourceFolder = '' # Default to current folder - } - - if($file.Keys -notcontains 'filter') { - $file.filter = '' # Default to all files - } - - if($file.Keys -notcontains 'origin') { - $file.origin = 'template' # Default to template - } - - if($file.Keys -notcontains 'type') { - $file.type = '' # Default to empty - } - - if($file.Keys -notcontains 'destinationFolder') { - # If destinationFolder is not specified, use the sourceFolder, so that the file structure is preserved - $file.destinationFolder = $file.sourceFolder - } - - if($file.Keys -notcontains 'perProject') { - $file.perProject = $false # Default to false - } - - # If originalSourceFolder is not specified, it means there is no custom template, so skip custom template files - if(!$originalSourceFolder -and $file.origin -eq 'custom template') { - OutputDebug "Skipping custom template file(s) with source folder '$($file.sourceFolder)' as there is no original source folder specified" - continue; - } - - # All files are relative to the template folder - OutputDebug "Resolving files for source folder '$($file.sourceFolder)' and filter '$($file.filter)'" - $sourceFiles = @(Get-ChildItem -Path (Join-Path $sourceFolder $file.sourceFolder) -Filter $file.filter -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName) - - OutputDebug "Found $($sourceFiles.Count) files for filter '$($file.filter)' in folder '$($file.sourceFolder)' (relative to folder '$sourceFolder', origin '$($file.origin)')" - - if(-not $sourceFiles) { - OutputDebug "No files found for filter '$($file.filter)' in folder '$($file.sourceFolder)' (relative to folder '$sourceFolder')" - continue - } - - foreach($srcFile in $sourceFiles) { - $fullFilePath = @{ - 'sourceFullPath' = $srcFile - 'originalSourceFullPath' = $null - 'type' = $file.type - 'destinationFullPath' = $null - } - - # Check if the source file is under the source folder - if ($srcFile -notlike "$sourceFolder*") { - OutputDebug "Skipping source file '$($srcFile)' as it is not under the source folder '$($sourceFolder)'." - continue - } - - OutputDebug "Processing file '$($srcFile)'" - - # Try to find the same files in the original template folder if it is specified. Exclude custom template files - if ($originalSourceFolder -and ($file.origin -ne 'custom template')) { - Push-Location $sourceFolder - $relativePath = Resolve-Path -Path $srcFile -Relative # resolve the path relative to the current location (template folder) - Pop-Location - if (Test-Path (Join-Path $originalSourceFolder $relativePath) -PathType Leaf) { - # If the file exists in the original template folder, use that file instead - $fullFilePath.originalSourceFullPath = Join-Path $originalSourceFolder $relativePath -Resolve - } - } - - $destinationName = Split-Path -Path $srcFile -Leaf - if($file.Keys -contains 'destinationName' -and ($file.destinationName)) { - $destinationName = $file.destinationName - } - - if($file.Keys -contains 'perProject' -and $file.perProject -eq $true) { - # Multiple file entries, one for each project - # Destination full path is the destination base folder + project + destinationFolder + destinationName - - foreach($project in $projects) { - if($project -eq '.') { - $project = '' # If project is '.', it means the root folder, so we use an empty string - } - - $fullProjectFilePath = $fullFilePath.Clone() - - $fullProjectFilePath.destinationFullPath = Join-Path $destinationFolder $project - $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $file.destinationFolder - $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $destinationName - - if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullProjectFilePath.destinationFullPath) { - OutputDebug "Skipping duplicate per-project file for project '$project': destinationFullPath '$($fullProjectFilePath.destinationFullPath)' already exists" - continue - } - OutputDebug "Adding per-project file for project '$project': sourceFullPath '$($fullProjectFilePath.sourceFullPath)', originalSourceFullPath '$($fullProjectFilePath.originalSourceFullPath)', destinationFullPath '$($fullProjectFilePath.destinationFullPath)'" - $fullFilePaths += $fullProjectFilePath - } - } - else { - # Single file entry - # Destination full path is the destination base folder + destinationFolder + destinationName - - $fullFilePath.destinationFullPath = Join-Path $destinationFolder $file.destinationFolder - $fullFilePath.destinationFullPath = Join-Path $fullFilePath.destinationFullPath $destinationName - - if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullFilePath.destinationFullPath) { - OutputDebug "Skipping duplicate file: destinationFullPath '$($fullFilePath.destinationFullPath)' already exists" - continue - } - OutputDebug "Adding file: sourceFullPath '$($fullFilePath.sourceFullPath)', originalSourceFullPath '$($fullFilePath.originalSourceFullPath)', destinationFullPath '$($fullFilePath.destinationFullPath)'" - $fullFilePaths += $fullFilePath - } - } - } - - return @($fullFilePaths) -} - -<# -.SYNOPSIS -Resolves file paths for files that already exist in the destination folder (e.g. files to remove). - -.DESCRIPTION -This function is a wrapper around ResolveFilePaths for resolving file specs that live in the destination -(target repo) folder rather than in a template folder (source and destination are the same folder). - -Before calling ResolveFilePaths, each file spec is normalized: -- destinationFolder (if specified) is used as the effective sourceFolder, because in the destination repo - the file lives at its destination path, not at the template-relative source path. -- destinationName (if specified) overrides the filter, because the file may have been renamed at the destination. -- perProject is cleared to $false on the normalized spec; per-project expansion is handled directly here. - -For files marked as perProject, the function iterates over all projects and calls ResolveFilePaths once per -project with a project-scoped folder (destinationFolder/project) as both source and destination. -For files not marked as perProject, ResolveFilePaths is called once with destinationFolder as both source -and destination. - -.PARAMETER destinationFolder -The folder that serves as both source and destination. Typically the root of the target repository. - -.PARAMETER files -An array of hashtables specifying the files to resolve. Each hashtable can contain the following keys: -- sourceFolder: The subfolder within the destination folder to search for files (default is current folder). - Note: if destinationFolder is specified, it takes precedence as the effective lookup folder. -- filter: The file filter to apply when searching for files (default is all files). - Note: if destinationName is specified, it overrides the filter. -- type: The type of the files (default is empty). -- destinationFolder: The subfolder within the destination folder where the files are located. Takes - precedence over sourceFolder as the effective lookup folder. -- destinationName: The expected filename in the destination folder. Overrides filter when specified. -- perProject: A boolean indicating whether the files are per project (default is false). - -.PARAMETER projects -An array of project names used when resolving per-project file paths. - -.OUTPUTS -An array of hashtables, each containing: -- sourceFullPath: The full path to the source file. -- originalSourceFullPath: Always $null (no original template folder in this context). -- type: The type of the file. -- destinationFullPath: The full path to the destination file. -#> -function ResolveFilePathsInDestinationFolder { - Param( - [Parameter(Mandatory=$true)] - [string] $destinationFolder, - [array] $files = @(), - [string[]] $projects = @() - ) - if(-not $files) { - return @() - } - - $fullFilePaths = @() - foreach($file in $files) { - $destinationFile = $file.Clone() - $destinationFile.perProject = $false - - # Replace sourceFolder with destinationFolder (if specified) since we are resolving against the destination folder - if($file.Keys -contains 'destinationFolder') { - $destinationFile.sourceFolder = $file.destinationFolder - } - # Replace filter with destinationName (if specified) since we are resolving against the destination folder - if($file.Keys -contains 'destinationName' -and ($file.destinationName)) { - $destinationFile.filter = $file.destinationName - } - - if ($file.Keys -contains 'perProject' -and $file.perProject -eq $true) { - foreach ($project in $projects) { - if ($project -eq '.') { - $project = '' # If project is '.', it means the root folder, so we use an empty string - } - $projectFolder = Join-Path $destinationFolder $project - $fullFilePaths += ResolveFilePaths -sourceFolder $projectFolder -destinationFolder $projectFolder -files @($destinationFile) - } - } else { - $fullFilePaths += ResolveFilePaths -sourceFolder $destinationFolder -destinationFolder $destinationFolder -files @($destinationFile) - } - } - - return @($fullFilePaths) -} - -function GetDefaultFilesToInclude { - Param( - [switch] $includeCustomTemplateFiles - ) - - $filesToInclude = @( - [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = '*.yaml'; 'type' = 'workflow' } - [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = '*.yml'; 'type' = 'workflow' } - [ordered]@{ 'sourceFolder' = '.github'; 'filter' = '*.copy.md' } - [ordered]@{ 'sourceFolder' = '.github'; 'filter' = '*.ps1' } - [ordered]@{ 'sourceFolder' = '.github'; 'filter' = "$RepoSettingsFileName"; 'type' = 'settings' } - [ordered]@{ 'sourceFolder' = '.github'; 'filter' = '*.settings.json'; 'type' = 'settings' } - [ordered]@{ 'sourceFolder' = '.github/.agents'; 'filter' = '*.agent.md' } - - [ordered]@{ 'sourceFolder' = '.AL-Go'; 'filter' = '*.ps1'; 'perProject' = $true }, - [ordered]@{ 'sourceFolder' = '.AL-Go'; 'filter' = "$ALGoSettingsFileName"; 'perProject' = $true; 'type' = 'settings' } - ) - - if($includeCustomTemplateFiles) { - # If there is an original template folder, we also need to include custom template files (RepoSettings and ProjectSettings) - $filesToInclude += @( - [ordered]@{ 'sourceFolder' = ".github"; 'filter' = "$RepoSettingsFileName"; 'destinationName' = "$CustomTemplateRepoSettingsFileName"; 'origin' = 'custom template' } - [ordered]@{ 'sourceFolder' = ".AL-Go"; 'filter' = "$ALGoSettingsFileName"; 'destinationFolder' = '.github'; 'destinationName' = "$CustomTemplateProjectSettingsFileName"; 'origin' = 'custom template' } - ) - } - - return $filesToInclude -} - -function GetDefaultFilesToExclude { - Param( - $settings - ) - $filesToExclude = @() - - $includeBuildPP = $settings.type -eq 'PTE' -and $settings.powerPlatformSolutionFolder -ne '' - if (!$includeBuildPP) { - # Remove PowerPlatform workflows if no PowerPlatformSolution exists - $filesToExclude += @( - [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = '_BuildPowerPlatformSolution.yaml' } - [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = 'PushPowerPlatformChanges.yaml' } - [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = 'PullPowerPlatformChanges.yaml' } - ) - } - - return @($filesToExclude) -} - -<# -.SYNOPSIS - Reads settings using the current custom template repository settings without changing the workspace. -.DESCRIPTION - Temporarily refreshes the custom template repository settings snapshot, reads the merged settings, and restores - the snapshot to its original state. This allows the current template settings to affect the current run while - preserving the workspace state for the normal update comparison. -.PARAMETER baseFolder - The base folder of the repository whose settings are read. -.PARAMETER templateFolder - The folder where the custom template files are located. -#> -function ReadSettingsWithCurrentCustomTemplateRepoSettings { - Param( - [Parameter(Mandatory=$true)] - [string] $baseFolder, - [Parameter(Mandatory=$true)] - [string] $templateFolder - ) - - $templateFolderRepoSettingsPath = Join-Path $templateFolder $RepoSettingsFile - - $baseFolderTemplateSettingsPath = Join-Path $baseFolder $CustomTemplateRepoSettingsFile - $baseFolderTemplateSettingsBackupPath = $null - - if (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { - $baseFolderTemplateSettingsBackupPath = Join-Path (GetTemporaryPath) ([Guid]::NewGuid().ToString()) - Copy-Item -LiteralPath $baseFolderTemplateSettingsPath -Destination $baseFolderTemplateSettingsBackupPath -Force - } - - try { - if (Test-Path -LiteralPath $templateFolderRepoSettingsPath -PathType Leaf) { - Copy-Item -LiteralPath $templateFolderRepoSettingsPath -Destination $baseFolderTemplateSettingsPath -Force - } - return ReadSettings -baseFolder $baseFolder -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse - } - finally { - if ($baseFolderTemplateSettingsBackupPath) { - Copy-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Destination $baseFolderTemplateSettingsPath -Force - Remove-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Force - } - elseif (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { - Remove-Item -LiteralPath $baseFolderTemplateSettingsPath -Force - } - } -} - -<# -.SYNOPSIS - Compute the lists of files to include, exclude, and remove when synchronizing a repository from its AL-Go template. -.DESCRIPTION - Builds three lists by merging defaults, repository settings, and the original AL-Go template (if given): - - 1. filesToInclude: Files to copy from the template or original template to the destination. - Built from default files to include and customALGoFiles.filesToInclude in settings, resolved against the template folder and original template folder (if any). - 2. filesToExclude: Files to skip from copying; if they already exist in the destination they should be deleted. - Built from default files to exclude and customALGoFiles.filesToExclude in settings, resolved against the template folder and original template folder (if any). - 3. filesToRemove: Files to unconditionally delete from the destination. - Built from customALGoFiles.filesToRemove in settings and resolved against template folder, the original template folder (if any), and the destination folder. - - Note: when a custom template is in use, the caller is expected to call - ReadSettingsWithCurrentCustomTemplateRepoSettings before this function, so that the template's - customALGoFiles/unusedALGoSystemFiles are already merged into settings. - - The deprecated unusedALGoSystemFiles setting is also applied: matching files are moved from filesToInclude to - filesToExclude with a deprecation warning. -.PARAMETER settings - The settings object containing customALGoFiles (filesToInclude, filesToExclude, filesToRemove) and the - deprecated unusedALGoSystemFiles configuration. -.PARAMETER baseFolder - The base folder of the repository. This is the target folder where the files will be updated. -.PARAMETER templateFolder - The folder where the template files are located. -.PARAMETER originalTemplateFolder - The folder where the original AL-Go template files are located (if any). - When provided, it signals that a custom template is in use. Both filesToInclude and filesToExclude specs are - resolved against this folder in addition to templateFolder; entries not already covered by originalSourceFullPath - tracking are appended to propagate upstream template additions and deletions to consumer repositories. -.PARAMETER projects - The list of projects in the repository. - The projects are used to resolve per-project files. -.OUTPUTS - An array containing three elements: the list of files to include, the list of files to exclude, and the list of - files to remove. - All entries are hashtables with the following keys: - - sourceFullPath: The full path to the source file. - - originalSourceFullPath: The full path to the original source file in the original template repository (if any). - Always $null for filesToRemove entries. - - type: The type of the file (e.g., workflow, settings). - - destinationFullPath: The full path to the destination file in the target repository. -#> -function GetFilesToUpdate { - Param( - [Parameter(Mandatory=$true)] - $settings, - [Parameter(Mandatory=$true)] - $baseFolder, - [Parameter(Mandatory=$true)] - $templateFolder, - $originalTemplateFolder = $null, - $projects = @() - ) - - $hasOriginalTemplate = $null -ne $originalTemplateFolder - Write-Host "Getting files to update from template folder '$templateFolder', original template folder '$originalTemplateFolder' and base folder '$baseFolder'" - - # Send telemetery about customALGoFiles usage - if ($settings.customALGoFiles.filesToInclude.Count -gt 0) { - Trace-Information -Message "Usage: Custom AL-Go Files (Include)" - } - if ($settings.customALGoFiles.filesToExclude.Count -gt 0) { - Trace-Information -Message "Usage: Custom AL-Go Files (Exclude)" - } - if ($settings.customALGoFiles.filesToRemove.Count -gt 0) { - Trace-Information -Message "Usage: Custom AL-Go Files (Remove)" - } - - # Determine files to include - $filesToIncludeUnresolved = GetDefaultFilesToInclude -includeCustomTemplateFiles:$hasOriginalTemplate - $filesToIncludeUnresolved += $settings.customALGoFiles.filesToInclude - $filesToInclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) - if ($hasOriginalTemplate) { - $filesToInclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) - } - # Remove duplicates based on destinationFullPath, keeping the first one (default > settings; template folder > original template folder) - $filesToInclude = @($filesToInclude | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[0] }) - - # Determine files to exclude - $filesToExcludeUnresolved = GetDefaultFilesToExclude -settings $settings - $filesToExcludeUnresolved += $settings.customALGoFiles.filesToExclude - $filesToExclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) - if ($hasOriginalTemplate) { - $filesToExclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) - } - # Note: unlike filesToInclude/filesToRemove, filesToExclude is not deduplicated by destinationFullPath here. - # Its destinationFullPath is never part of the actual output; only sourceFullPath is used below to match against filesToInclude. - - # Determine files to remove - $filesToRemoveUnresolved = @($settings.customALGoFiles.filesToRemove) - $filesToRemove = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) - if ($hasOriginalTemplate) { - $filesToRemove += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) - } - $filesToRemove += @(ResolveFilePathsInDestinationFolder -destinationFolder $baseFolder -files $filesToRemoveUnresolved -projects $projects) - # Remove duplicates based on destinationFullPath, keeping the first one (default > settings; template folder > original template folder > base folder) - $filesToRemove = @($filesToRemove | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[0] }) - - # Exclude files from filesToInclude that are in filesToRemove (based on destination) - $filesToInclude = @($filesToInclude | Where-Object { - $fileToInclude = $_ - $include = -not ($filesToRemove | Where-Object { $_.destinationFullPath -eq $fileToInclude.destinationFullPath }) - if (-not $include) { OutputDebug "Excluding destination file '$($fileToInclude.destinationFullPath)' from include list as it is in the remove list" } - return $include - }) - - # Map files from filesToExclude to files that are in filesToInclude (based on source) - # Settings for filesToExclude only define the sources (sourceFolder and filter) but not the destinations (destinationFolder, destinationName and perProject) - $filesToExclude = @($filesToInclude | Where-Object { - $fileToInclude = $_ - return $filesToExclude | Where-Object { $_.sourceFullPath -eq $fileToInclude.sourceFullPath } - }) - - # Exclude files from filesToInclude that are in filesToExclude (based on source) - $filesToInclude = @($filesToInclude | Where-Object { - $file = $_ - $include = -not ($filesToExclude | Where-Object { $_.sourceFullPath -eq $file.sourceFullPath }) - if (-not $include) { OutputDebug "Excluding source file '$($file.sourceFullPath)' from include list as it is in the exclude list" } - return $include - }) - - # Apply unusedALGoSystemFiles logic - $unusedALGoSystemFiles = $settings.unusedALGoSystemFiles - - # Exclude unusedALGoSystemFiles from $filesToInclude and add them to $filesToExclude - $unusedFilesToExclude = $filesToInclude | Where-Object { $unusedALGoSystemFiles -contains (Split-Path -Path $_.sourceFullPath -Leaf) } - if ($unusedFilesToExclude) { - Trace-DeprecationWarning "The 'unusedALGoSystemFiles' setting is deprecated and will be removed in future versions." -DeprecationTag "unusedALGoSystemFiles" - - OutputDebug "The following files are marked as unused and will be removed if they exist:" - $unusedFilesToExclude | ForEach-Object { OutputDebug "- $($_.destinationFullPath)" } - - $filesToInclude = @($filesToInclude | Where-Object { $unusedALGoSystemFiles -notcontains (Split-Path -Path $_.sourceFullPath -Leaf) }) - $filesToExclude += @($unusedFilesToExclude) - } - - # List all files to be included and excluded with their source and destination paths, type and original source path (if any) - $fileFormatter = { param($file) " -Source: $($file.sourceFullPath), Destination: $($file.destinationFullPath), Type: $($file.type), Original Source: $($file.originalSourceFullPath)"} - OutputArray -Message "Files to include: $($filesToInclude.Count)" -Array $filesToInclude -Formatter $fileFormatter - OutputArray -Message "Files to exclude: $($filesToExclude.Count)" -Array $filesToExclude -Formatter $fileFormatter - OutputArray -Message "Files to remove: $($filesToRemove.Count)" -Array $filesToRemove -Formatter $fileFormatter - - return @($filesToInclude), @($filesToExclude), @($filesToRemove) -} +Import-Module (Join-Path $PSScriptRoot '../.Modules/ReadSettings.psm1') -DisableNameChecking +Import-Module (Join-Path $PSScriptRoot '../.Modules/DebugLogHelper.psm1') -DisableNameChecking + +<# +.SYNOPSIS +Downloads a template repository and returns the path to the downloaded folder +.PARAMETER token +The GitHub token / PAT to use for authentication (if the template repository is private/internal) +.PARAMETER templateUrl +The URL to the template repository +.PARAMETER templateSha +The SHA of the template repository (returned by reference if downloadLatest is true) +.PARAMETER downloadLatest +If true, the latest SHA of the template repository will be downloaded +#> +function DownloadTemplateRepository { + Param( + [string] $token, + [string] $templateUrl, + [ref] $templateSha, + [bool] $downloadLatest + ) + + $templateRepositoryUrl = $templateUrl.Split('@')[0] + $templateRepository = $templateRepositoryUrl.Split('/')[-2..-1] -join '/' + + # Use Authenticated API request if possible to avoid the 60 API calls per hour limit + OutputDebug -message "Getting template repository ($templateRepository) with GITHUB_TOKEN" + $headers = GetHeaders -token $env:GITHUB_TOKEN -repository $templateRepository + try { + $response = Invoke-WebRequest -UseBasicParsing -Headers $headers -Method Head -Uri $templateRepositoryUrl + OutputDebug -message ($response | Format-List | Out-String) + } + catch { + # Ignore error + OutputDebug -message "Error getting template repository with GITHUB_TOKEN:" + OutputDebug -message $_ + $response = $null + } + if (-not $response -or $response.StatusCode -ne 200) { + # GITHUB_TOKEN doesn't have access to template repository, must be private/internal + # Get token with read permissions for the template repository + # NOTE that the GitHub app needs to be installed in the template repository for this to work + $headers = GetHeaders -token $token -repository $templateRepository + } + + # Construct API URL + $apiUrl = $templateUrl.Split('@')[0] -replace "^(https:\/\/github\.com\/)(.*)$", "$ENV:GITHUB_API_URL/repos/`$2" + + Write-Host "TemplateUrl: $templateUrl" + Write-Host "TemplateSha: $($templateSha.Value)" + Write-Host "DownloadLatest: $downloadLatest" + + if ($downloadLatest) { + # Get latest commit SHA from the template repository + $templateSha.Value = GetLatestTemplateSha -headers $headers -apiUrl $apiUrl -templateUrl $templateUrl + Write-Host "Latest SHA for $($templateUrl): $($templateSha.Value)" + } + $archiveUrl = "$apiUrl/zipball/$($templateSha.Value)" + Write-Host "Using ArchiveUrl: $archiveUrl" + + # Download template repository + $tempName = Join-Path (GetTemporaryPath) ([Guid]::NewGuid().ToString()) + InvokeWebRequest -Headers $headers -Uri $archiveUrl -OutFile "$tempName.zip" + Expand-7zipArchive -Path "$tempName.zip" -DestinationPath $tempName + Remove-Item -Path "$tempName.zip" + + return $tempName +} + +function GetLatestTemplateSha { + Param( + [hashtable] $headers, + [string] $apiUrl, + [string] $templateUrl + ) + + $branch = $templateUrl.Split('@')[1] + Write-Host "Get latest SHA for $templateUrl" + try { + $branchInfo = (InvokeWebRequest -Headers $headers -Uri "$apiUrl/branches/$branch").Content | ConvertFrom-Json + } catch { + throw "Failed to update AL-Go System Files. Could not get the latest SHA from template ($templateUrl). (Error was $($_.Exception.Message))" + } + return $branchInfo.commit.sha +} + +function ModifyCICDWorkflow { + Param( + [Yaml] $yaml, + [hashtable] $repoSettings + ) + + # The CICD workflow can have a RepoSetting called CICDPushBranches, which will be used to set the branches for the workflow + # Setting the CICDSchedule will disable the push trigger for the CI/CD workflow (unless CICDPushBranches is set) + if ($repoSettings.Keys -contains 'CICDPushBranches') { + $CICDPushBranches = $repoSettings.CICDPushBranches + } + elseif ($repoSettings.Keys -contains $workflowScheduleKey) { + $CICDPushBranches = '' + } + else { + $CICDPushBranches = $defaultCICDPushBranches + } + # update the branches: line with the new branches + if ($CICDPushBranches) { + $yaml.Replace('on:/push:/branches:', "branches: [ '$($CICDPushBranches -join "', '")' ]") + } + else { + $yaml.Replace('on:/push:',@()) + } +} + +function ModifyPullRequestHandlerWorkflow { + Param( + [Yaml] $yaml, + [hashtable] $repoSettings + ) + # The PullRequestHandler workflow can have a RepoSetting called pullRequestTrigger which specifies the trigger to use for Pull Requests + $triggerSection = $yaml.Get('on:/pull') + $triggerSection.content = "$($repoSettings.pullRequestTrigger):" + $yaml.Replace('on:/pull', $triggerSection.Content) + + # The PullRequestHandler workflow can have a RepoSetting called CICDPullRequestBranches, which will be used to set the branches for the workflow + if ($repoSettings.Keys -contains 'CICDPullRequestBranches') { + $CICDPullRequestBranches = $repoSettings.CICDPullRequestBranches + } + else { + $CICDPullRequestBranches = $defaultCICDPullRequestBranches + } + + # update the branches: line with the new branches + $yaml.Replace("on:/$($repoSettings.pullRequestTrigger):/branches:", "branches: [ '$($CICDPullRequestBranches -join "', '")' ]") +} + +function ModifyRunsOnAndShell { + Param( + [Yaml] $yaml, + [hashtable] $repoSettings + ) + + # The default for runs-on is windows-latest and the default for shell is powershell + # The default for GitHubRunner/GitHubRunnerShell is runs-on/shell (unless Ubuntu-latest are selected here, as build jobs cannot run on Ubuntu) + # We do not change runs-on in Update AL-Go System Files and Pull Request Handler workflows + # These workflows will always run on windows-latest (or maybe Ubuntu-latest later) and not follow settings + # Reasons: + # - Update AL-Go System files is needed for changing runs-on - by having non-functioning runners, you might dead-lock yourself + # - Pull Request Handler workflow for security reasons + if ($repoSettings."runs-on" -ne "windows-latest") { + Write-Host "Setting runs-on to [ $($repoSettings."runs-on") ]" + $yaml.ReplaceAll('runs-on: [ windows-latest ]', "runs-on: [ $($repoSettings."runs-on") ]") + } + if ($repoSettings.shell -ne "powershell" -and $repoSettings.shell -ne "pwsh") { + throw "The shell can only be set to powershell or pwsh" + } + if ($repoSettings."runs-on" -eq "ubuntu-latest" -and $repoSettings.shell -eq "powershell") { + throw "The shell cannot be set to powershell when runs-on is ubuntu-latest. Use pwsh instead." + } + Write-Host "Setting shell to $($repoSettings.shell)" + $yaml.ReplaceAll('shell: powershell', "shell: $($repoSettings.shell)") +} + +function ModifyBuildWorkflows { + Param( + [Yaml] $yaml, + [int] $depth, + [bool] $includeBuildPP + ) + + $yaml.Replace('env:/workflowDepth:',"workflowDepth: $depth") + $build = $yaml.Get('jobs:/Build:/') + $buildPP = $yaml.Get('jobs:/BuildPP:/') + $deliver = $yaml.Get('jobs:/Deliver:/') + $deploy = $yaml.Get('jobs:/Deploy:/') + $deployALDoc = $yaml.Get('jobs:/DeployALDoc:/') + $codeAnalysisUpload = $yaml.Get('jobs:/CodeAnalysisUpload:/') + $postProcess = $yaml.Get('jobs:/PostProcess:/') + if (!$build) { + throw "No build job found in the workflow" + } + + # Duplicate the build job for each dependency depth + $newBuild = @() + for($index = 0; $index -lt $depth; $index++) { + # All build job needs to have a dependency on the Initialization job + $needs = @('Initialization') + if ($index -eq 0) { + # First build job needs to have a dependency on the Initialization job only + # Example (depth 1): + # needs: [ Initialization ] + # if: (!failure()) && (!cancelled()) && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 + $if = "if: (!failure()) && (!cancelled()) && fromJson(needs.Initialization.outputs.buildOrderJson)[$index].projectsCount > 0" + } + else { + # Subsequent build jobs needs to have a dependency on all previous build jobs + # Example (depth 2): + # needs: [ Initialization, Build1 ] + # if: (!failure()) && (!cancelled()) && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 + # Another example (depth 3): + # needs: [ Initialization, Build2, Build1 ] + # if: (!failure()) && (!cancelled()) && (needs.Build2.result == 'success' || needs.Build2.result == 'skipped') && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 + $newBuild += @('') + $ifpart = "" + $index..1 | ForEach-Object { + $needs += @("Build$_") + $ifpart += " && (needs.Build$_.result == 'success' || needs.Build$_.result == 'skipped')" + } + $if = "if: (!failure()) && (!cancelled())$ifpart && fromJson(needs.Initialization.outputs.buildOrderJson)[$index].projectsCount > 0" + } + + # Replace the if:, the needs: and the strategy/matrix/project: in the build job with the correct values + $build.Replace('if:', $if) + $build.Replace('needs:', "needs: [ $($needs -join ', ') ]") + $build.Replace('strategy:/matrix:/include:',"include: `${{ fromJson(needs.Initialization.outputs.buildOrderJson)[$index].buildDimensions }}") + + # Last build job is called build, all other build jobs are called build1, build2, etc. + if ($depth -eq ($index + 1)) { + $newBuild += @("Build:") + } + else { + $newBuild += @("Build$($index + 1):") + } + + # Add the content of the calculated build job to the new build job list with an indentation of 2 spaces + $build.content | ForEach-Object { $newBuild += @(" $_") } + } + + # Replace the entire build: job with the new build job list + $yaml.Replace('jobs:/Build:', $newBuild) + + if (!$includeBuildPP -and $buildPP) { + # Remove the BuildPP job from the workflow + [int]$start = 0 + [int]$count = 0 + if ($yaml.Find('jobs:/BuildPP:', [ref] $start, [ref] $count)) { + $yaml.Remove($start, $count+1) + } + } + + $needs += @("Build") + $ifpart += " && (needs.Build.result == 'success' || needs.Build.result == 'skipped')" + if ($includeBuildPP -and $buildPP) { + $needs += @("BuildPP") + $ifpart += " && (needs.BuildPP.result == 'success' || needs.BuildPP.result == 'skipped')" + } + + $postProcessNeeds = $needs + # Modify Deliver and Deploy steps depending on build jobs + if ($deploy) { + $deploy.Replace('needs:', "needs: [ $($needs -join ', ') ]") + $deploy.Replace('if:', "if: (!cancelled())$ifpart && needs.Initialization.outputs.environmentCount > 0") + $yaml.Replace('jobs:/Deploy:/', $deploy.content) + $postProcessNeeds += @('Deploy') + } + if ($deliver) { + $deliver.Replace('needs:', "needs: [ $($needs -join ', ') ]") + $deliver.Replace('if:', "if: (!cancelled())$ifpart && needs.Initialization.outputs.deliveryTargetsJson != '[]'") + $yaml.Replace('jobs:/Deliver:/', $deliver.content) + $postProcessNeeds += @('Deliver') + } + if ($deployALDoc) { + $postProcessNeeds += @('DeployALDoc') + } + if ($codeAnalysisUpload) { + $postProcessNeeds += @('CodeAnalysisUpload') + } + if ($postProcess) { + $postProcess.Replace('needs:', "needs: [ $($postProcessNeeds -join ', ') ]") + $yaml.Replace('jobs:/PostProcess:/', $postProcess.content) + } +} + +function ModifyUpdateALGoSystemFiles { + Param( + [Yaml] $yaml, + [hashtable] $repoSettings + ) + + if($repoSettings.Keys -notcontains 'UpdateALGoSystemFilesEnvironment') { + # If UpdateALGoSystemFilesEnvironment is not set, we don't need to do anything + return + } + + $updateALGoSystemFilesEnvironment = $repoSettings.UpdateALGoSystemFilesEnvironment + Write-Host "Setting 'Update AL-Go System Files' environment to $updateALGoSystemFilesEnvironment" + + # Add or replace the environment: section in the UpdateALGoSystemFiles job + $updateALGoSystemFilesJob = $yaml.Get('jobs:/UpdateALGoSystemFiles:/') + + if(-not $updateALGoSystemFilesJob) { + # Defensively check that the UpdateALGoSystemFiles job exists + throw "No UpdateALGoSystemFiles job found in the workflow" + } + + $environmentSection = $updateALGoSystemFilesJob.Get('environment:') + if($environmentSection) { + # If the environment: section already exists, replace it with the new environment + $updateALGoSystemFilesJob.Replace($environmentSection, "environment: $updateALGoSystemFilesEnvironment") + } + else { + # If the environment: section does not exist, add it + $updateALGoSystemFilesJob.Insert(1, "environment: $updateALGoSystemFilesEnvironment") + } + + $yaml.Replace('jobs:/UpdateALGoSystemFiles:/', $updateALGoSystemFilesJob.content) +} + +function ApplyWorkflowDefaultInputs { + Param( + [Yaml] $yaml, + [hashtable] $repoSettings, + [string] $workflowName + ) + + # Check if workflow_dispatch inputs exist + $workflowDispatch = $yaml.Get('on:/workflow_dispatch:/') + if (-not $workflowDispatch) { + # No workflow_dispatch section, nothing to do + return + } + + $inputs = $workflowDispatch.Get('inputs:/') + if (-not $inputs) { + # No inputs section, nothing to do + return + } + + if ($repoSettings.workflowDefaultInputs.Count -eq 0) { + # No defaults for this workflow + return + } + + # Get workflow_call inputs + $workflowCallInputs = $yaml.Get('on:/workflow_call:/inputs:/') + + # Apply defaults to matching inputs + $workflowDispatchInputsModified = $false + $workflowCallInputsModified = $false + + foreach ($defaultInput in $repoSettings.workflowDefaultInputs) { + # Apply to workflow_dispatch inputs and only update workflow_call if dispatch was modified + if (ApplyWorkflowDefaultInput -workflowName $workflowName -inputs $inputs -defaultInput $defaultInput) { + $workflowDispatchInputsModified = $true + + # Only apply to workflow_call inputs if the workflow_dispatch input was modified + if ($workflowCallInputs) { + if (ApplyWorkflowDefaultInput -workflowName $workflowName -inputs $workflowCallInputs -defaultInput $defaultInput) { + $workflowCallInputsModified = $true + } + } + } + } + + # Update the workflow_dispatch section if modified + if ($workflowDispatchInputsModified) { + $workflowDispatch.Replace('inputs:/', $inputs.content) + $yaml.Replace('on:/workflow_dispatch:/', $workflowDispatch.content) + } + + # Update workflow_call inputs if modified + if ($workflowCallInputsModified) { + $yaml.Replace('on:/workflow_call:/inputs:/', $workflowCallInputs.content) + } +} + +function ApplyWorkflowDefaultInput { + Param( + [string] $workflowName, + [Yaml] $inputs, + [hashtable] $defaultInput + ) + + $inputName = $defaultInput.name + $defaultValue = $defaultInput.value + + # Check if this input exists in the inputs collection + $inputSection = $inputs.Get("$($inputName):/") + if (-not $inputSection) { + # Input is not present in the workflow + return $false + } + + # Get the input type from the YAML if specified + $inputType = $null + $typeStart = 0 + $typeCount = 0 + if ($inputSection.Find('type:', [ref] $typeStart, [ref] $typeCount)) { + $typeLine = $inputSection.content[$typeStart].Trim() + if ($typeLine -match 'type:\s*(.+)') { + $inputType = $matches[1].Trim() + } + } + + # Validate that the value type matches the input type + $validationError = $null + if ($inputType) { + switch ($inputType) { + 'boolean' { + if ($defaultValue -isnot [bool]) { + $validationError = "Workflow '$workflowName', input '$inputName': Expected boolean value, but got $($defaultValue.GetType().Name). Please use `$true or `$false." + } + } + 'number' { + if ($defaultValue -isnot [int] -and $defaultValue -isnot [long] -and $defaultValue -isnot [double]) { + $validationError = "Workflow '$workflowName', input '$inputName': Expected number value, but got $($defaultValue.GetType().Name)." + } + } + 'string' { + if ($defaultValue -isnot [string]) { + $validationError = "Workflow '$workflowName', input '$inputName': Expected string value, but got $($defaultValue.GetType().Name)." + } + } + 'choice' { + # Choice inputs accept strings and must match one of the available options (case-sensitive) + if ($defaultValue -isnot [string]) { + $validationError = "Workflow '$workflowName', input '$inputName': Expected string value for choice input, but got $($defaultValue.GetType().Name)." + } + else { + # Validate that the value is one of the available options + $optionsStart = 0 + $optionsCount = 0 + if ($inputSection.Find('options:', [ref] $optionsStart, [ref] $optionsCount)) { + $availableOptions = @() + # Parse the options from the YAML (they are indented list items starting with "- ") + for ($i = $optionsStart + 1; $i -lt ($optionsStart + $optionsCount); $i++) { + $optionLine = $inputSection.content[$i].Trim() + if ($optionLine -match '^-\s*(.+)$') { + $availableOptions += $matches[1].Trim() + } + } + + if ($availableOptions.Count -gt 0 -and $availableOptions -cnotcontains $defaultValue) { + $validationError = "Workflow '$workflowName', input '$inputName': Value '$defaultValue' is not a valid choice (case-sensitive match required). Available options: $($availableOptions -join ', ')." + } + } + } + } + } + } + else { + # If no type is specified in the workflow, it defaults to string + if ($defaultValue -isnot [string]) { + OutputWarning "Workflow '$workflowName', input '$inputName': No type specified in workflow (defaults to string), but configured value is $($defaultValue.GetType().Name). This may cause issues." + } + } + + if ($validationError) { + throw $validationError + } + + # Convert the default value to the appropriate YAML format + $yamlValue = $defaultValue + if ($defaultValue -is [bool]) { + $yamlValue = $defaultValue.ToString().ToLower() + } + elseif ($defaultValue -is [string]) { + # Quote strings and escape single quotes per YAML spec + $escapedValue = $defaultValue.Replace("'", "''") + $yamlValue = "'$escapedValue'" + } + + # Find and replace the default: line in the input section + $start = 0 + $count = 0 + + if ($inputSection.Find('default:', [ref] $start, [ref] $count)) { + # Replace existing default value + $inputSection.Replace('default:', "default: $yamlValue") + } + else { + # Add default value - find the best place to insert it + # Insert after type, required, or description (whichever comes last) + $insertAfter = -1 + $typeLine = 0 + $typeCount = 0 + $requiredLine = 0 + $requiredCount = 0 + $descLine = 0 + $descCount = 0 + + if ($inputSection.Find('type:', [ref] $typeLine, [ref] $typeCount)) { + $insertAfter = $typeLine + $typeCount + } + if ($inputSection.Find('required:', [ref] $requiredLine, [ref] $requiredCount)) { + if (($requiredLine + $requiredCount) -gt $insertAfter) { + $insertAfter = $requiredLine + $requiredCount + } + } + if ($inputSection.Find('description:', [ref] $descLine, [ref] $descCount)) { + if (($descLine + $descCount) -gt $insertAfter) { + $insertAfter = $descLine + $descCount + } + } + + if ($insertAfter -eq -1) { + # No other properties, insert at position 1 (after the input name) + $insertAfter = 1 + } + + $inputSection.Insert($insertAfter, "default: $yamlValue") + } + + # Update the inputs collection with the modified input section + $inputs.Replace("$($inputName):/", $inputSection.content) + + return $true +} + +function GetWorkflowContentWithChangesFromSettings { + Param( + [string] $srcFile, + [hashtable] $repoSettings, + [int] $depth + ) + + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($srcFile) + $yaml = [Yaml]::Load($srcFile) + $yamlName = $yaml.get('name:') + if ($yamlName) { + $workflowName = $yamlName.content.SubString('name:'.Length).Trim().Trim('''"').Trim() + } + else { + $workflowName = $baseName + } + + $workflowScheduleKey = "WorkflowSchedule" + $workflowConcurrencyKey = "WorkflowConcurrency" + foreach($key in @($workflowScheduleKey,$workflowConcurrencyKey)) { + if ($repoSettings.Keys -contains $key -and ($repoSettings."$key")) { + throw "The $key setting is not allowed in the global repository settings. Please use the workflow specific settings file or conditional settings." + } + } + + # Re-read settings and this time include workflow specific settings + $repoSettings = ReadSettings -buildMode '' -project '' -workflowName $workflowName -userName '' -branchName '' | ConvertTo-HashTable -recurse + + # Old Schedule key is deprecated, but still supported + $oldWorkflowScheduleKey = "$($baseName)Schedule" + if ($repoSettings.Keys -contains $oldWorkflowScheduleKey) { + # DEPRECATION: REPLACE WITH ERROR AFTER October 1st 2025 ---> + if ($repoSettings.Keys -contains $workflowScheduleKey) { + OutputWarning "Both $oldWorkflowScheduleKey and $workflowScheduleKey are defined in the settings file. $oldWorkflowScheduleKey will be ignored. This warning will become an error in the future" + } + else { + Trace-DeprecationWarning -Message "$oldWorkflowScheduleKey is deprecated" -DeprecationTag "_workflow_Schedule" -WillBecomeError + # Convert the old Schedule setting to the new WorkflowSchedule setting + $repoSettings."$workflowScheduleKey" = @{ "cron" = $repoSettings."$oldWorkflowScheduleKey" } + } + # <--- REPLACE WITH ERROR AFTER October 1st 2025 + } + + # Any workflow (except for the PullRequestHandler and reusable workflows (_*)) can have concurrency and schedule defined + if ($baseName -ne "PullRequestHandler" -and $baseName -notlike '_*') { + # Add Schedule and Concurrency settings to the workflow + if ($repoSettings.Keys -contains $workflowScheduleKey) { + if ($repoSettings."$workflowScheduleKey" -isnot [hashtable] -or $repoSettings."$workflowScheduleKey".Keys -notcontains 'cron' -or $repoSettings."$workflowScheduleKey".cron -isnot [string]) { + throw "The $workflowScheduleKey setting must be a structure containing a cron property" + } + # Replace or add the schedule part under the on: key + $yaml.ReplaceOrAdd('on:/', 'schedule:', @("- cron: '$($repoSettings."$workflowScheduleKey".cron)'")) + } + if ($repoSettings.Keys -contains $workflowConcurrencyKey) { + # Replace or add the concurrency part + $yaml.ReplaceOrAdd('', 'concurrency:', $repoSettings."$workflowConcurrencyKey") + } + } + + if ($baseName -eq "CICD") { + ModifyCICDWorkflow -yaml $yaml -repoSettings $repoSettings + } + + if ($baseName -eq "PullRequestHandler") { + ModifyPullRequestHandlerWorkflow -yaml $yaml -repoSettings $repoSettings + } + + $criticalWorkflows = @('UpdateGitHubGoSystemFiles', 'Troubleshooting') + $allowedRunners = @('windows-latest', 'ubuntu-latest') + $modifyRunsOnAndShell = $true + + # Critical workflows may only run on allowed runners (must always be able to run) + if($criticalWorkflows -contains $baseName) { + if($allowedRunners -notcontains $repoSettings."runs-on") { + $modifyRunsOnAndShell = $false + } + } + + if ($modifyRunsOnAndShell) { + ModifyRunsOnAndShell -yaml $yaml -repoSettings $repoSettings + } + + # PullRequestHandler, CICD, Current, NextMinor and NextMajor workflows all include a build step. + # If the dependency depth is higher than 1, we need to add multiple dependent build jobs to the workflow + if ($baseName -eq 'PullRequestHandler' -or $baseName -eq 'CICD' -or $baseName -eq 'Current' -or $baseName -eq 'NextMinor' -or $baseName -eq 'NextMajor') { + $includeBuildPP = $repoSettings.type -eq 'PTE' -and $repoSettings.powerPlatformSolutionFolder -ne '' + ModifyBuildWorkflows -yaml $yaml -depth $depth -includeBuildPP $includeBuildPP + } + + if($baseName -eq 'UpdateGitHubGoSystemFiles') { + ModifyUpdateALGoSystemFiles -yaml $yaml -repoSettings $repoSettings + } + + # Apply workflow input defaults from settings + if ($repoSettings.Keys -contains 'workflowDefaultInputs') { + ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName $workflowName + } + + # combine all the yaml file lines into a single string with LF line endings + $yaml.content -join "`n" +} + +# Using direct AL-Go repo, we need to change the owner to the templateOwner, the repo names to AL-Go and AL-Go/Actions and the branch to templateBranch + +function ReplaceOwnerRepoAndBranch { + Param( + [ref] $srcContent, + [string] $templateOwner, + [string] $templateBranch + ) + + $lines = $srcContent.Value.Split("`n") + + # The Original Owner and Repo in the AL-Go repository are microsoft/AL-Go-Actions, microsoft/AL-Go-PTE and microsoft/AL-Go-AppSource + $originalOwnerAndRepo = @{ + "actionsRepo" = "microsoft/AL-Go-Actions" + "perTenantExtensionRepo" = "microsoft/AL-Go-PTE" + "appSourceAppRepo" = "microsoft/AL-Go-AppSource" + } + # Original branch is always main + $originalBranch = "main" + + # Modify the file to use repository names based on whether or not we are using the direct AL-Go repo + $templateRepos = @{ + "actionsRepo" = "AL-Go/Actions" + "perTenantExtensionRepo" = "AL-Go" + "appSourceAppRepo" = "AL-Go" + } + + # Replace URL's to actions repository first + $regex = "^(.*)https:\/\/raw\.githubusercontent\.com\/microsoft\/AL-Go-Actions\/$originalBranch(.*)$" + $replace = "`${1}https://raw.githubusercontent.com/$($templateOwner)/AL-Go/$($templateBranch)/Actions`${2}" + $lines = $lines | ForEach-Object { $_ -replace $regex, $replace } + + # Replace the owner and repo names in the workflow + "actionsRepo","perTenantExtensionRepo","appSourceAppRepo" | ForEach-Object { + $regex = "^(.*)$($originalOwnerAndRepo."$_")(.*)$originalBranch(.*)$" + $replace = "`${1}$($templateOwner)/$($templateRepos."$_")`${2}$($templateBranch)`${3}" + $lines = $lines | ForEach-Object { $_ -replace $regex, $replace } + } + $srcContent.Value = $lines -join "`n" +} + +function IsDirectALGo { + param ( + [string] $templateUrl + ) + $directALGo = $templateUrl -like 'https://github.com/*/AL-Go@*' + if ($directALGo) { + if ($templateUrl -like 'https://github.com/microsoft/AL-Go@*' -and -not ($templateUrl -like 'https://github.com/microsoft/AL-Go@*/*')) { + throw "You cannot use microsoft/AL-Go as a template repository. Please use microsoft/AL-Go-PTE, microsoft/AL-Go-AppSource or a fork of AL-Go instead." + } + } + return $directALGo +} + +function GetSrcFolder { + Param( + [string] $repoType, + [string] $templateUrl, + [string] $templateFolder, + [string] $srcPath = '' + ) + + if (!$templateUrl) { + return '' + } + if (IsDirectALGo -templateUrl $templateUrl) { + switch ($repoType) { + "PTE" { + $typePath = "Per Tenant Extension" + } + "AppSource App" { + $typePath = "AppSource App" + } + default { + throw "Unknown repository type" + } + } + $path = Join-Path $templateFolder "*/Templates/$typePath/.github/workflows" + } + else { + $path = Join-Path $templateFolder "*/.github/workflows" + } + # Due to this PowerShell bug: https://github.com/PowerShell/PowerShell/issues/6473#issuecomment-375930843 + # We need to resolve the path of a non-hidden folder (.github/workflows) + # and then get the parent folder of the parent folder of that path + $path = Resolve-Path -Path $path -ErrorAction SilentlyContinue + if (!$path) { + throw "No workflows found in the template repository" + } + $path = Join-Path -Path (Split-Path -Path (Split-Path -Path $path -Parent) -Parent) -ChildPath $srcPath + return $path +} + +function GetModifiedSettingsContent { + Param( + [string] $srcSettingsFile, + [string] $dstSettingsFile + ) + + $srcSettings = Get-ContentLF -Path $srcSettingsFile | ConvertFrom-Json + + $dstSettings = $null + if(Test-Path -Path $dstSettingsFile -PathType Leaf) { + $dstSettings = Get-ContentLF -Path $dstSettingsFile | ConvertFrom-Json + } + + if(!$dstSettings) { + # If the destination settings file does not exist or it's empty, create an new settings object with default values from the source settings (which includes the $schema property already) + $dstSettings = $srcSettings + } + else { + # Change the $schema property to be the same as the source settings file (add it if it doesn't exist) + $schemaKey = '$schema' + if ($srcSettings.PSObject.Properties.Name -eq $schemaKey) { + $schemaValue = $srcSettings."$schemaKey" + + $dstSettings | Add-Member -MemberType NoteProperty -Name "$schemaKey" -Value $schemaValue -Force + + # Make sure the $schema property is the first property in the object + # PS5-safe: explicitly list properties instead of using wildcard to avoid literal '*' being added + $otherProperties = @($dstSettings.PSObject.Properties.Name | Where-Object { $_ -ne $schemaKey }) + $selectProperties = @($schemaKey) + $otherProperties + $dstSettings = $dstSettings | Select-Object -Property $selectProperties + } + } + + return $dstSettings | ConvertTo-JsonLF +} + +function UpdateSettingsFile { + Param( + [string] $settingsFile, + [hashtable] $updateSettings + ) + + $modified = $false + # Update Repo Settings file with the template URL + if (Test-Path $settingsFile) { + $settings = Get-Content $settingsFile -Encoding UTF8 | ConvertFrom-Json + } + else { + $settings = [PSCustomObject]@{} + $modified = $true + } + foreach($key in $updateSettings.Keys) { + if ($settings.PSObject.Properties.Name -eq $key) { + if ($settings."$key" -ne $updateSettings."$key") { + $settings."$key" = $updateSettings."$key" + $modified = $true + } + } + else { + # Add the property if it doesn't exist + $settings | Add-Member -MemberType NoteProperty -Name "$key" -Value $updateSettings."$key" + $modified = $true + } + } + if ($modified) { + # Save the file with LF line endings and UTF8 encoding + $settings | Set-JsonContentLF -path $settingsFile + } + return $modified +} + +<# +.SYNOPSIS +Resolves file paths based on the provided source folder, destination folder, and file specifications. + +.DESCRIPTION +This function takes a source folder, an optional original source folder, a destination folder, and an array of file specifications. It resolves the full paths for each specified file, considering their origin (template or custom template), type, and whether they are per-project files. +The function returns an array of hashtables containing the resolved source and destination file paths. +The function is used to determine which files need to be copied from the template repository to the target repository during the AL-Go update process. +sourceFolder: The base folder of the template used to resolve the source file paths. +originalSourceFolder: The base folder of the original template used to check for original files (can be $null). This is in the case of custom templates, where if the file exists in the original template, it should be used instead of the custom template file. +destinationFolder: The base folder used to construct the destination file paths. This is typically the root folder of the target repository. + +.PARAMETER sourceFolder +The base folder where the source files are located. + +.PARAMETER originalSourceFolder +The original source folder to check for original files (can be $null). All files of origin 'custom template' are skipped if this parameter is $null. + +.PARAMETER destinationFolder +The base folder used to construct the destination file paths. + +.PARAMETER files +An array of hashtables specifying the files to resolve. Each hashtable can contain the following keys: +- sourceFolder: The subfolder within the source folder to search for files (default is current folder). +- filter: The file filter to apply when searching for files (default is all files). +- origin: The origin of the files, either 'template' or 'custom template' (default is 'template'). +- type: The type of the files (default is empty). +- destinationFolder: The subfolder within the destination folder where the files should be placed (default is the same as sourceFolder). +- perProject: A boolean indicating whether the files are per project (default is false). + +.PARAMETER projects +An array of project names used when resolving per-project file paths. + +.OUTPUTS +An array of hashtables, each containing: +- sourceFullPath: The full path to the source file. +- originalSourceFullPath: The full path to the original source file (if found, otherwise $null). +- type: The type of the file. +- destinationFullPath: The full path to the destination file. +#> +function ResolveFilePaths { + Param( + [Parameter(Mandatory=$true)] + [string] $sourceFolder, + [string] $originalSourceFolder = $null, + [Parameter(Mandatory=$true)] + [string] $destinationFolder, + [array] $files = @(), + [string[]] $projects = @() + ) + + if(-not $files) { + return @() + } + + $sourceFolder = Join-Path $sourceFolder '' # Ensure source folder has a trailing slash for correct path resolution + + $fullFilePaths = @() + foreach($file in $files) { + if($file.Keys -notcontains 'sourceFolder') { + $file.sourceFolder = '' # Default to current folder + } + + if($file.Keys -notcontains 'filter') { + $file.filter = '' # Default to all files + } + + if($file.Keys -notcontains 'origin') { + $file.origin = 'template' # Default to template + } + + if($file.Keys -notcontains 'type') { + $file.type = '' # Default to empty + } + + if($file.Keys -notcontains 'destinationFolder') { + # If destinationFolder is not specified, use the sourceFolder, so that the file structure is preserved + $file.destinationFolder = $file.sourceFolder + } + + if($file.Keys -notcontains 'perProject') { + $file.perProject = $false # Default to false + } + + # If originalSourceFolder is not specified, it means there is no custom template, so skip custom template files + if(!$originalSourceFolder -and $file.origin -eq 'custom template') { + OutputDebug "Skipping custom template file(s) with source folder '$($file.sourceFolder)' as there is no original source folder specified" + continue; + } + + # All files are relative to the template folder + OutputDebug "Resolving files for source folder '$($file.sourceFolder)' and filter '$($file.filter)'" + $sourceFiles = @(Get-ChildItem -Path (Join-Path $sourceFolder $file.sourceFolder) -Filter $file.filter -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName) + + OutputDebug "Found $($sourceFiles.Count) files for filter '$($file.filter)' in folder '$($file.sourceFolder)' (relative to folder '$sourceFolder', origin '$($file.origin)')" + + if(-not $sourceFiles) { + OutputDebug "No files found for filter '$($file.filter)' in folder '$($file.sourceFolder)' (relative to folder '$sourceFolder')" + continue + } + + foreach($srcFile in $sourceFiles) { + $fullFilePath = @{ + 'sourceFullPath' = $srcFile + 'originalSourceFullPath' = $null + 'type' = $file.type + 'destinationFullPath' = $null + } + + # Check if the source file is under the source folder + if ($srcFile -notlike "$sourceFolder*") { + OutputDebug "Skipping source file '$($srcFile)' as it is not under the source folder '$($sourceFolder)'." + continue + } + + OutputDebug "Processing file '$($srcFile)'" + + # Try to find the same files in the original template folder if it is specified. Exclude custom template files + if ($originalSourceFolder -and ($file.origin -ne 'custom template')) { + Push-Location $sourceFolder + $relativePath = Resolve-Path -Path $srcFile -Relative # resolve the path relative to the current location (template folder) + Pop-Location + if (Test-Path (Join-Path $originalSourceFolder $relativePath) -PathType Leaf) { + # If the file exists in the original template folder, use that file instead + $fullFilePath.originalSourceFullPath = Join-Path $originalSourceFolder $relativePath -Resolve + } + } + + $destinationName = Split-Path -Path $srcFile -Leaf + if($file.Keys -contains 'destinationName' -and ($file.destinationName)) { + $destinationName = $file.destinationName + } + + if($file.Keys -contains 'perProject' -and $file.perProject -eq $true) { + # Multiple file entries, one for each project + # Destination full path is the destination base folder + project + destinationFolder + destinationName + + foreach($project in $projects) { + if($project -eq '.') { + $project = '' # If project is '.', it means the root folder, so we use an empty string + } + + $fullProjectFilePath = $fullFilePath.Clone() + + $fullProjectFilePath.destinationFullPath = Join-Path $destinationFolder $project + $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $file.destinationFolder + $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $destinationName + + if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullProjectFilePath.destinationFullPath) { + OutputDebug "Skipping duplicate per-project file for project '$project': destinationFullPath '$($fullProjectFilePath.destinationFullPath)' already exists" + continue + } + OutputDebug "Adding per-project file for project '$project': sourceFullPath '$($fullProjectFilePath.sourceFullPath)', originalSourceFullPath '$($fullProjectFilePath.originalSourceFullPath)', destinationFullPath '$($fullProjectFilePath.destinationFullPath)'" + $fullFilePaths += $fullProjectFilePath + } + } + else { + # Single file entry + # Destination full path is the destination base folder + destinationFolder + destinationName + + $fullFilePath.destinationFullPath = Join-Path $destinationFolder $file.destinationFolder + $fullFilePath.destinationFullPath = Join-Path $fullFilePath.destinationFullPath $destinationName + + if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullFilePath.destinationFullPath) { + OutputDebug "Skipping duplicate file: destinationFullPath '$($fullFilePath.destinationFullPath)' already exists" + continue + } + OutputDebug "Adding file: sourceFullPath '$($fullFilePath.sourceFullPath)', originalSourceFullPath '$($fullFilePath.originalSourceFullPath)', destinationFullPath '$($fullFilePath.destinationFullPath)'" + $fullFilePaths += $fullFilePath + } + } + } + + return @($fullFilePaths) +} + +function GetDefaultFilesToInclude { + Param( + [switch] $includeCustomTemplateFiles + ) + + $filesToInclude = @( + [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = '*.yaml'; 'type' = 'workflow' } + [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = '*.yml'; 'type' = 'workflow' } + [ordered]@{ 'sourceFolder' = '.github'; 'filter' = '*.copy.md' } + [ordered]@{ 'sourceFolder' = '.github'; 'filter' = '*.ps1' } + [ordered]@{ 'sourceFolder' = '.github'; 'filter' = "$RepoSettingsFileName"; 'type' = 'settings' } + [ordered]@{ 'sourceFolder' = '.github'; 'filter' = '*.settings.json'; 'type' = 'settings' } + [ordered]@{ 'sourceFolder' = '.github/.agents'; 'filter' = '*.agent.md' } + + [ordered]@{ 'sourceFolder' = '.AL-Go'; 'filter' = '*.ps1'; 'perProject' = $true }, + [ordered]@{ 'sourceFolder' = '.AL-Go'; 'filter' = "$ALGoSettingsFileName"; 'perProject' = $true; 'type' = 'settings' } + ) + + if($includeCustomTemplateFiles) { + # If there is an original template folder, we also need to include custom template files (RepoSettings and ProjectSettings) + $filesToInclude += @( + [ordered]@{ 'sourceFolder' = ".github"; 'filter' = "$RepoSettingsFileName"; 'destinationName' = "$CustomTemplateRepoSettingsFileName"; 'origin' = 'custom template' } + [ordered]@{ 'sourceFolder' = ".AL-Go"; 'filter' = "$ALGoSettingsFileName"; 'destinationFolder' = '.github'; 'destinationName' = "$CustomTemplateProjectSettingsFileName"; 'origin' = 'custom template' } + ) + } + + return $filesToInclude +} + +function GetDefaultFilesToExclude { + Param( + $settings + ) + $filesToExclude = @() + + $includeBuildPP = $settings.type -eq 'PTE' -and $settings.powerPlatformSolutionFolder -ne '' + if (!$includeBuildPP) { + # Remove PowerPlatform workflows if no PowerPlatformSolution exists + $filesToExclude += @( + [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = '_BuildPowerPlatformSolution.yaml' } + [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = 'PushPowerPlatformChanges.yaml' } + [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = 'PullPowerPlatformChanges.yaml' } + ) + } + + return @($filesToExclude) +} + +<# +.SYNOPSIS + Reads settings using the current custom template repository settings without changing the workspace. +.DESCRIPTION + Temporarily refreshes the custom template repository settings snapshot, reads the merged settings, and restores + the snapshot to its original state. This allows the current template settings to affect the current run while + preserving the workspace state for the normal update comparison. +.PARAMETER baseFolder + The base folder of the repository whose settings are read. +.PARAMETER templateFolder + The folder where the custom template files are located. +#> +function ReadSettingsWithCurrentCustomTemplateRepoSettings { + Param( + [Parameter(Mandatory=$true)] + [string] $baseFolder, + [Parameter(Mandatory=$true)] + [string] $templateFolder + ) + + $templateFolderRepoSettingsPath = Join-Path $templateFolder $RepoSettingsFile + + $baseFolderTemplateSettingsPath = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $baseFolderTemplateSettingsBackupPath = $null + + if (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { + $baseFolderTemplateSettingsBackupPath = Join-Path (GetTemporaryPath) ([Guid]::NewGuid().ToString()) + Copy-Item -LiteralPath $baseFolderTemplateSettingsPath -Destination $baseFolderTemplateSettingsBackupPath -Force + } + + try { + if (Test-Path -LiteralPath $templateFolderRepoSettingsPath -PathType Leaf) { + Copy-Item -LiteralPath $templateFolderRepoSettingsPath -Destination $baseFolderTemplateSettingsPath -Force + } + return ReadSettings -baseFolder $baseFolder -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse + } + finally { + if ($baseFolderTemplateSettingsBackupPath) { + Copy-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Destination $baseFolderTemplateSettingsPath -Force + Remove-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Force + } + elseif (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { + Remove-Item -LiteralPath $baseFolderTemplateSettingsPath -Force + } + } +} + +<# +.SYNOPSIS + Get the list of files from the template repository to include and exclude based on the provided settings. +.DESCRIPTION + Builds two lists by merging defaults, repository settings, and the original AL-Go template (if given): + + 1. filesToInclude: Files to copy from the template or original template to the destination. + Built from default files to include and customALGoFiles.filesToInclude in settings, resolved against the template folder and original template folder (if any). + 2. filesToExclude: Files to skip from copying; if they already exist in the destination they should be deleted. + Built from default files to exclude and customALGoFiles.filesToExclude in settings, resolved against the template folder and original template folder (if any). + + Note: when a custom template is in use, the caller is expected to call + ReadSettingsWithCurrentCustomTemplateRepoSettings before this function, so that the template's + customALGoFiles/unusedALGoSystemFiles are already merged into settings. + + The deprecated unusedALGoSystemFiles setting is also applied: matching files are moved from filesToInclude to + filesToExclude with a deprecation warning. +.PARAMETER settings + The settings object containing the customALGoFiles configuration. +.PARAMETER baseFolder + The base folder of the repository. This is the target folder where the files will be updated. +.PARAMETER templateFolder + The folder where the template files are located. +.PARAMETER originalTemplateFolder + The folder where the original AL-Go template files are located (if any). + When provided, it signals that a custom template is in use. Both filesToInclude and filesToExclude specs are + resolved against this folder in addition to templateFolder; entries not already covered by originalSourceFullPath + tracking are appended to propagate upstream template additions and deletions to consumer repositories. +.PARAMETER projects + The list of projects in the repository. + The projects are used to resolve per-project files. +.OUTPUTS + An array containing two elements: the list of files to include and the list of files to exclude. + Files are represented as hashtables with the following keys: + - sourceFullPath: The full path to the source file. + - originalSourceFullPath: The full path to the original source file in the original template repository (if any). + - type: The type of the file (e.g., workflow, settings). + - destinationFullPath: The full path to the destination file in the target repository. +#> +function GetFilesToUpdate { + Param( + [Parameter(Mandatory=$true)] + $settings, + [Parameter(Mandatory=$true)] + $baseFolder, + [Parameter(Mandatory=$true)] + $templateFolder, + $originalTemplateFolder = $null, + $projects = @() + ) + + $hasOriginalTemplate = $null -ne $originalTemplateFolder + Write-Host "Getting files to update from template folder '$templateFolder', original template folder '$originalTemplateFolder' and base folder '$baseFolder'" + + # Send telemetery about customALGoFiles usage + if ($settings.customALGoFiles.filesToInclude.Count -gt 0) { + Trace-Information -Message "Usage: Custom AL-Go Files (Include)" + } + if ($settings.customALGoFiles.filesToExclude.Count -gt 0) { + Trace-Information -Message "Usage: Custom AL-Go Files (Exclude)" + } + # Determine files to include + $filesToIncludeUnresolved = GetDefaultFilesToInclude -includeCustomTemplateFiles:$hasOriginalTemplate + $filesToIncludeUnresolved += $settings.customALGoFiles.filesToInclude + $filesToInclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) + if ($hasOriginalTemplate) { + $filesToInclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) + } + # Deduplicate files to include based on destinationFullPath, keeping the first one (default > settings; template folder > original template folder) + $filesToInclude = @($filesToInclude | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[0] }) + + # Determine files to exclude + $filesToExcludeUnresolved = GetDefaultFilesToExclude -settings $settings + $filesToExcludeUnresolved += $settings.customALGoFiles.filesToExclude + $filesToExclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) + if ($hasOriginalTemplate) { + $filesToExclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) + } + # filesToExclude is not deduplicated by destinationFullPath here. + # Its destinationFullPath is never part of the actual output; only sourceFullPath is used below to match against filesToInclude. + + # Map files from filesToExclude to files that are in filesToInclude (based on source) + # Settings for filesToExclude only define the sources (sourceFolder and filter) but not the destinations (destinationFolder, destinationName and perProject) + $filesToExclude = @($filesToInclude | Where-Object { + $fileToInclude = $_ + return $filesToExclude | Where-Object { $_.sourceFullPath -eq $fileToInclude.sourceFullPath } + }) + + # Exclude files from filesToInclude that are in filesToExclude (based on source) + $filesToInclude = @($filesToInclude | Where-Object { + $file = $_ + $include = -not ($filesToExclude | Where-Object { $_.sourceFullPath -eq $file.sourceFullPath }) + if (-not $include) { OutputDebug "Excluding source file '$($file.sourceFullPath)' from include list as it is in the exclude list" } + return $include + }) + + # Apply unusedALGoSystemFiles logic + $unusedALGoSystemFiles = $settings.unusedALGoSystemFiles + + # Exclude unusedALGoSystemFiles from $filesToInclude and add them to $filesToExclude + $unusedFilesToExclude = $filesToInclude | Where-Object { $unusedALGoSystemFiles -contains (Split-Path -Path $_.sourceFullPath -Leaf) } + if ($unusedFilesToExclude) { + Trace-DeprecationWarning "The 'unusedALGoSystemFiles' setting is deprecated and will be removed in future versions." -DeprecationTag "unusedALGoSystemFiles" + + OutputDebug "The following files are marked as unused and will be removed if they exist:" + $unusedFilesToExclude | ForEach-Object { OutputDebug "- $($_.destinationFullPath)" } + + $filesToInclude = @($filesToInclude | Where-Object { $unusedALGoSystemFiles -notcontains (Split-Path -Path $_.sourceFullPath -Leaf) }) + $filesToExclude += @($unusedFilesToExclude) + } + + # List all files to be included and excluded with their source and destination paths, type and original source path (if any) + $fileFormatter = { param($file) " -Source: $($file.sourceFullPath), Destination: $($file.destinationFullPath), Type: $($file.type), Original Source: $($file.originalSourceFullPath)"} + OutputArray -Message "Files to include: $($filesToInclude.Count)" -Array $filesToInclude -Formatter $fileFormatter + OutputArray -Message "Files to exclude: $($filesToExclude.Count)" -Array $filesToExclude -Formatter $fileFormatter + + return @($filesToInclude), @($filesToExclude) +} diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index 63ee3532fb..9b8a3f7127 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -121,7 +121,7 @@ if ($originalTemplateFolder) { $projects = @(GetProjectsFromRepository -baseFolder $baseFolder -projectsFromSettings $repoSettings.projects) -$filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $repoSettings -projects $projects -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder +$filesToInclude, $filesToExclude = GetFilesToUpdate -settings $repoSettings -projects $projects -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder # $updateFiles will hold an array of files, which needs to be updated $updateFiles = @() @@ -209,8 +209,8 @@ foreach($fileToInclude in $filesToInclude) { } Push-Location -Path $baseFolder -# Remove files that are in $filesToExclude or $filesToRemove and exist in the repository -$removeFiles = @($filesToExclude) + @($filesToRemove) | Where-Object { $_ -and (Test-Path -Path $_.destinationFullPath -PathType Leaf) } | ForEach-Object { +# Remove files that are in $filesToExclude and exist in the repository +$removeFiles = @($filesToExclude) | Where-Object { $_ -and (Test-Path -Path $_.destinationFullPath -PathType Leaf) } | ForEach-Object { $relativePath = Resolve-Path -Path $_.destinationFullPath -Relative Write-Host "File marked for removal: $relativePath" $relativePath diff --git a/RELEASENOTES.md b/RELEASENOTES.md index b8d848ae07..b18a093f12 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,11 +1,10 @@ ### Enhanced `customALGoFiles` setting -The `customALGoFiles` setting of a custom template was only applied on the next Update (from `AL-Go-TemplateRepoSettings.doNotEdit.json`). Now the up-to-date settings of the custom template are used directly during "Update AL-Go System Files". The template's `filesToInclude`, `filesToExclude`, and `filesToRemove` settings are merged with the consumer repo's settings before resolution. +The `customALGoFiles` setting of a custom template was only applied on the next Update (from `AL-Go-TemplateRepoSettings.doNotEdit.json`). Now the up-to-date settings of the custom template are used directly during "Update AL-Go System Files". The template's `filesToInclude` and `filesToExclude` settings are merged with the consumer repo's settings before resolution. - **`filesToInclude`** now also resolves files from the original AL-Go template. Files present in the official template are propagated even when they are absent from your custom template. When a file exists in both, the official template supplies the base content; for workflow files, customizations from the custom template are reapplied. - **`filesToExclude`** now also resolves files from the original AL-Go template (same dual-resolution as `filesToInclude`). Files resolved by `filesToInclude` whose source matches a `filesToExclude` entry are not copied to consumer repos, and existing copies are removed. -- **`filesToRemove`** (new property): Unconditionally removes matching files from consumer repos. Files are searched in both the template and end repository. Takes precedence over `filesToInclude`. Entries use `sourceFolder` (relative to the template), `filter`, and optionally `destinationFolder`, `perProject`, and `destinationName`. -- **`destinationName`** (new property on `filesToInclude` and `filesToRemove`): Allows renaming a file at the destination. When set, the file is written to (or removed from) `/` instead of keeping the source filename. For `filesToRemove`, `destinationName` also overrides the `filter` when looking up the file to delete in the end repository. +- **`destinationName`** (new property on `filesToInclude`): Allows renaming a file at the destination. When set, the file is written to `/` instead of keeping the source filename. Read more at [Customizing AL-Go for GitHub](Scenarios/CustomizingALGoForGitHub.md#Using-custom-template-files). diff --git a/Scenarios/CustomizingALGoForGitHub.md b/Scenarios/CustomizingALGoForGitHub.md index 3e90cdff50..b8c7c581e3 100644 --- a/Scenarios/CustomizingALGoForGitHub.md +++ b/Scenarios/CustomizingALGoForGitHub.md @@ -231,7 +231,7 @@ Repositories based on your custom template will notify you that changes are avai When updating AL-Go for GitHub, only specific system files from the template repository are synced to your end repository by default. Files such as `README.md`, `.gitignore`, and other documentation or non-system files are not updated by AL-Go for GitHub. By default, AL-Go syncs workflow files in `.github/workflows`, PowerShell scripts in `.github` and `.AL-Go`, and configuration files required for AL-Go operations. When using custom template repositories, you may need to add additional files related to AL-Go for GitHub, such as script overrides, complementary workflows, or centrally managed files not part of the official AL-Go templates. -In order to instruct AL-Go which files to look for at the template repository, you need to define the `customALGoFiles` setting. The setting is an object that can contain three properties: `filesToInclude`, `filesToExclude`, and `filesToRemove`. +In order to instruct AL-Go which files to look for at the template repository, you need to define the `customALGoFiles` setting. The setting is an object that can contain two properties: `filesToInclude` and `filesToExclude`. `filesToInclude`, as the name suggests, is an array of file configurations that will instruct AL-Go which files to include (create/update). Every item in the array may contain the following properties: @@ -278,28 +278,9 @@ The following table summarizes how AL-Go for GitHub manages file updates and exc | Yes | No | Yes | The file is **_not_** removed as it was not matched as update | | No | Yes/No | Yes | The file is **_not_ created** in the end repo, as it's matched for exclusion | -`filesToRemove` is an array of file configurations that will instruct AL-Go which files to unconditionally remove from the end repository. Unlike `filesToExclude`, files matched by `filesToRemove` do not need to be part of `filesToInclude` first — they are removed regardless of whether they are included in the update process. Files are searched in both the template repository and the end repository. Every item in the array may contain the following properties: - -- `sourceFolder`: A path to a folder, relative to the template, where to look for files. If not specified the root folder is implied. `*` characters are not supported. _Example_: `.github/workflows`. -- `filter`: A string to use for filtering in the specified source path. It can contain `*` and `?` wildcards. _Example_: `deprecated-*.yaml` or `oldScript.ps1`. -- `destinationFolder`: A path to a folder, relative to the end repository, where the files are located. If not specified, defaults to the same as the source file folder. _Example_: `.github/workflows`. -- `perProject`: A boolean that indicates whether the matched files should be removed for all available AL-Go projects. In that case, `destinationFolder` is relative to the project folder. _Example_: `.AL-Go/scripts`. -- `destinationName`: The filename of the file at the destination. If specified, overrides the `filter` when looking up the file to remove. Should be used together with a `filter` that matches a single file. _Example_: `renamedScript.ps1`. - -> [!NOTE] -> `filesToRemove` takes precedence over both `filesToInclude` and `filesToExclude`. If a file is matched by `filesToRemove`, it will be removed from the end repository even if it is also matched by `filesToInclude`. The file will also be excluded from the `filesToInclude` and `filesToExclude` lists, so it will not be created or updated. - -The following table summarizes how AL-Go for GitHub manages file removals when using `filesToRemove`: - -| File is present in end repo | File is matched by `filesToInclude` | File is matched by `filesToExclude` | File is matched by `filesToRemove` | Result | -|---|---|---|---|---| -| Yes/No | Yes/No | Yes/No | No | See `filesToInclude`/`filesToExclude` behavior above | -| Yes | Yes/No | Yes/No | Yes | The file is **removed** from the end repo (remove wins over include/exclude) | -| No | Yes/No | Yes/No | Yes | The file is **not created** in the end repo (excluded from `filesToInclude`/`filesToExclude`) | - ### Examples of using custom template files -Below are examples of how to use the `filesToInclude`, `filesToExclude`, and `filesToRemove` settings in your AL-Go configuration. +Below are examples of how to use the `filesToInclude` and `filesToExclude` settings in your AL-Go configuration. #### Example 1: Updating specific scripts for all projects @@ -385,61 +366,6 @@ Note that AL-Go for GitHub already syncs all workflow files under `.github/workf This configuration updates all JSON files from `shared/config` and all PowerShell scripts from `.github/scripts`, but excludes `legacy-config.json` from being updated or created. -#### Example 5: Removing a deprecated workflow from all consumer repos - -```json -"customALGoFiles": { - "filesToRemove": [ - { - "sourceFolder": ".github/workflows", - "filter": "deprecated-workflow.yaml" - } - ] -} -``` - -This configuration will remove `deprecated-workflow.yaml` from the `.github/workflows` folder in all consumer repositories, regardless of whether it is still present in the template. This is useful when a workflow has been retired and should be cleaned up from all repositories. - -#### Example 6: Removing per-project scripts that are no longer needed - -```json -"customALGoFiles": { - "filesToRemove": [ - { - "sourceFolder": ".AL-Go/scripts", - "filter": "OldBuildScript.ps1", - "perProject": true - } - ] -} -``` - -This will remove `OldBuildScript.ps1` from the `.AL-Go/scripts` folder of every AL-Go project in the target repository. - -#### Example 7: Combining `filesToInclude` with `filesToRemove` for migration - -```json -"customALGoFiles": { - "filesToInclude": [ - { - "sourceFolder": ".github/scripts", - "filter": "NewBuildHelper.ps1", - "destinationFolder": ".AL-Go/scripts", - "perProject": true - } - ], - "filesToRemove": [ - { - "sourceFolder": ".AL-Go/scripts", - "filter": "LegacyBuildHelper.ps1", - "perProject": true - } - ] -} -``` - -This configuration introduces a new script (`NewBuildHelper.ps1`) to all projects while simultaneously removing the old script (`LegacyBuildHelper.ps1`) it replaces. This pattern is useful for migrating from one file to another across all consumer repositories. - These examples demonstrate how you can fine-tune which files are propagated from your template repository and which are excluded, giving you granular control over your AL-Go customization process. ## Forking AL-Go for GitHub and making your "own" **public** version diff --git a/Scenarios/settings.md b/Scenarios/settings.md index 972f02a5dc..6e25f88301 100644 --- a/Scenarios/settings.md +++ b/Scenarios/settings.md @@ -247,7 +247,7 @@ Please read the release notes carefully when installing new versions of AL-Go fo | BcContainerHelperVersion | This setting can be set to a specific version (ex. 3.0.8) of BcContainerHelper to force AL-Go to use this version. **latest** means that AL-Go will use the latest released version. **preview** means that AL-Go will use the latest preview version. **dev** means that AL-Go will use the dev branch of containerhelper. | latest (or preview for AL-Go preview) | | unusedALGoSystemFiles (**deprecated**) | An array of AL-Go System Files, which won't be updated during Update AL-Go System Files. They will instead be removed.
Use this setting with care, as this can break the AL-Go for GitHub functionality and potentially leave your repo no longer functional. | [ ] | | reportSuppressedDiagnostics | If this setting is set to true, the AL compiler will report diagnostics which are suppressed in the code using the pragma `#pragma warning disable `. This can be useful if you want to ensure that no warnings are suppressed in your code. | false | -| customALGoFiles | An object to configure custom AL-Go files, that will be updated during "Update AL-Go System Files" workflow. The object can contain properties `filesToInclude`, `filesToExclude`, and `filesToRemove`. Read more at [Customizing AL-Go](CustomizingALGoForGitHub.md#Using-custom-template-files). | `{ "filesToInclude": [], "filesToExclude": [], "filesToRemove": [] }` +| customALGoFiles | An object to configure custom AL-Go files, that will be updated during "Update AL-Go System Files" workflow. The object can contain properties `filesToInclude` and `filesToExclude`. Read more at [Customizing AL-Go](CustomizingALGoForGitHub.md#Using-custom-template-files). | `{ "filesToInclude": [], "filesToExclude": [] }` ## Overwrite settings diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 2af5354c19..b3a5fa8fe7 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -1,4 +1,4 @@ -Get-Module TestActionsHelper | Remove-Module -Force +Get-Module TestActionsHelper | Remove-Module -Force Import-Module (Join-Path $PSScriptRoot 'TestActionsHelper.psm1') Import-Module (Join-Path $PSScriptRoot "../Actions/TelemetryHelper.psm1") Import-Module (Join-Path $PSScriptRoot '../Actions/.Modules/ReadSettings.psm1') @@ -1848,181 +1848,6 @@ Describe "ResolveFilePaths" { } } -Describe "ResolveFilePathsInDestinationFolder" { - BeforeAll { - $actionName = "CheckForUpdates" - $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve - . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") - - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'destinationFolder', Justification = 'False positive.')] - $destinationFolder = Join-Path $PSScriptRoot "destResolveFolder" - - New-Item -Path (Join-Path $destinationFolder "folder/File1.txt") -ItemType File -Force | Out-Null - New-Item -Path (Join-Path $destinationFolder "folder/File2.log") -ItemType File -Force | Out-Null - New-Item -Path (Join-Path $destinationFolder "folder/File3.txt") -ItemType File -Force | Out-Null - New-Item -Path (Join-Path $destinationFolder "folder/File4.md") -ItemType File -Force | Out-Null - New-Item -Path (Join-Path $destinationFolder "project1/folder/File1.txt") -ItemType File -Force | Out-Null - New-Item -Path (Join-Path $destinationFolder "project2/folder/File1.txt") -ItemType File -Force | Out-Null - - # File tree: - # destResolveFolder/ - # ├── folder/ - # │ ├── File1.txt - # │ ├── File2.log - # │ ├── File3.txt - # │ └── File4.md - # └── project1/ - # └── folder/ - # └── File1.txt - # └── project2/ - # └── folder/ - # └── File1.txt - } - - AfterAll { - if (Test-Path $destinationFolder) { - Remove-Item -Path $destinationFolder -Recurse -Force - } - } - - It 'Returns files matching filter in sourceFolder relative to destinationFolder' { - $files = @(@{ sourceFolder = "folder"; filter = "*.txt" }) - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) - - $result | Should -Not -BeNullOrEmpty - $result.Count | Should -Be 2 - $result.sourceFullPath | Should -Contain (Join-Path $destinationFolder "folder/File1.txt") - $result.sourceFullPath | Should -Contain (Join-Path $destinationFolder "folder/File3.txt") - $result.destinationFullPath | Should -Contain (Join-Path $destinationFolder "folder/File1.txt") - $result.destinationFullPath | Should -Contain (Join-Path $destinationFolder "folder/File3.txt") - } - - It 'destinationFolder key overrides sourceFolder for lookup' { - $files = @(@{ sourceFolder = "folder"; filter = "File1.txt"; destinationFolder = "project1/folder" }) - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) - - $result | Should -Not -BeNullOrEmpty - $result.Count | Should -Be 1 - $result[0].sourceFullPath | Should -Be (Join-Path $destinationFolder "project1/folder/File1.txt") - $result[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "project1/folder/File1.txt") - } - - It 'destinationName key overrides filter for filename lookup' { - $files = @(@{ sourceFolder = "folder"; filter = "File2.log"; destinationName = "File1.txt" }) - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) - - $result | Should -Not -BeNullOrEmpty - $result.Count | Should -Be 1 - $result[0].sourceFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") - $result[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") - } - - It 'Both destinationFolder and destinationName combined resolve the correct file' { - $files = @(@{ sourceFolder = "folder"; filter = "File2.log"; destinationFolder = "project1/folder"; destinationName = "File1.txt" }) - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) - - $result | Should -Not -BeNullOrEmpty - $result.Count | Should -Be 1 - $result[0].sourceFullPath | Should -Be (Join-Path $destinationFolder "project1/folder/File1.txt") - $result[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "project1/folder/File1.txt") - } - - It 'perProject=true with a single project scopes results to that project subfolder' { - $files = @(@{ sourceFolder = "folder"; filter = "File1.txt"; perProject = $true }) - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files -projects @('project1')) - - $result | Should -Not -BeNullOrEmpty - $result.Count | Should -Be 1 - $result[0].sourceFullPath | Should -Be (Join-Path $destinationFolder "project1/folder/File1.txt") - $result[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "project1/folder/File1.txt") - } - - It "perProject=true with project '.' maps to the root of destinationFolder" { - $files = @(@{ sourceFolder = "folder"; filter = "File1.txt"; perProject = $true }) - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files -projects @('.')) - - $result | Should -Not -BeNullOrEmpty - $result.Count | Should -Be 1 - $result[0].sourceFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") - $result[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") - } - - It 'perProject=true with empty projects array returns empty result' { - $files = @(@{ sourceFolder = "folder"; filter = "*.txt"; perProject = $true }) - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files -projects @()) - - $result | Should -BeNullOrEmpty - } - - It 'Mixed perProject=true and perProject=false files in same call returns correct results' { - $files = @( - @{ sourceFolder = "folder"; filter = "*.log" } # non-perProject - @{ sourceFolder = "folder"; filter = "File1.txt"; perProject = $true } # perProject - ) - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files -projects @('project1')) - - $result | Should -Not -BeNullOrEmpty - $result.Count | Should -Be 2 - $result.sourceFullPath | Should -Contain (Join-Path $destinationFolder "folder/File2.log") - $result.sourceFullPath | Should -Contain (Join-Path $destinationFolder "project1/folder/File1.txt") - } - - It 'perProject=true with multiple projects returns one entry per project' { - $files = @(@{ sourceFolder = "folder"; filter = "File1.txt"; perProject = $true }) - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files -projects @('project1', 'project2')) - - $result | Should -Not -BeNullOrEmpty - $result.Count | Should -Be 2 - $result.sourceFullPath | Should -Contain (Join-Path $destinationFolder "project1/folder/File1.txt") - $result.sourceFullPath | Should -Contain (Join-Path $destinationFolder "project2/folder/File1.txt") - } - - It 'originalSourceFullPath is always $null for all returned entries' { - $files = @(@{ sourceFolder = "folder" }) - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) - - $result | Should -Not -BeNullOrEmpty - foreach ($entry in $result) { - $entry.originalSourceFullPath | Should -Be $null - } - } - - It 'Returns empty array when files parameter is $null' { - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $null) - - $result | Should -BeNullOrEmpty - } - - It 'Returns empty array when files parameter is an empty array' { - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files @()) - - $result | Should -BeNullOrEmpty - } - - It 'Returns empty array when no files match the filter' { - $files = @(@{ sourceFolder = "folder"; filter = "*.nonexistent" }) - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) - - $result | Should -BeNullOrEmpty - } - - It 'Skips files outside the destinationFolder when sourceFolder traverses up' { - $externalFolder = Join-Path $PSScriptRoot "external" - New-Item -Path (Join-Path $externalFolder "external.txt") -ItemType File -Force | Out-Null - - try { - $files = @(@{ sourceFolder = "../external"; filter = "*.txt" }) - $result = @(ResolveFilePathsInDestinationFolder -destinationFolder $destinationFolder -files $files) - - # Should return no results since ../external is outside destinationFolder - $result | Should -BeNullOrEmpty - } - finally { - if (Test-Path $externalFolder) { Remove-Item -Path $externalFolder -Recurse -Force } - } - } -} - Describe "ReplaceOwnerRepoAndBranch" { BeforeAll { $actionName = "CheckForUpdates" @@ -2178,11 +2003,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.ps1" }) filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 @@ -2191,7 +2015,6 @@ Describe "GetFilesToUpdate (general files to update logic)" { # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty - $filesToRemove | Should -BeNullOrEmpty $settings = @{ type = "NotPTE" @@ -2199,11 +2022,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt" }) filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 $filesToInclude[0].sourceFullPath | Should -Be $testTxtFile @@ -2213,7 +2035,6 @@ Describe "GetFilesToUpdate (general files to update logic)" { # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty - $filesToRemove | Should -BeNullOrEmpty } It 'Returns the correct files with destinationFolder' { @@ -2223,11 +2044,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt"; destinationFolder = "customFolder" }) filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 @@ -2238,7 +2058,6 @@ Describe "GetFilesToUpdate (general files to update logic)" { # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty - $filesToRemove | Should -BeNullOrEmpty $settings = @{ type = "NotPTE" @@ -2246,11 +2065,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt"; destinationFolder = "customFolder" }) filesToExclude = @(@{ filter = "test2.txt" }) - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 @@ -2271,11 +2089,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "test.ps1"; destinationName = "renamed.txt" }) filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 @@ -2284,7 +2101,6 @@ Describe "GetFilesToUpdate (general files to update logic)" { # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty - $filesToRemove | Should -BeNullOrEmpty $settings = @{ type = "NotPTE" @@ -2292,11 +2108,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "test.ps1"; destinationFolder = 'dstPath'; destinationName = "renamed.txt" }) filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 @@ -2311,11 +2126,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.ps1"; type = "script" }) filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 @@ -2325,7 +2139,6 @@ Describe "GetFilesToUpdate (general files to update logic)" { # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty - $filesToRemove | Should -BeNullOrEmpty $settings = @{ type = "NotPTE" @@ -2333,11 +2146,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt"; type = "text" }) filesToExclude = @(@{ filter = "test.txt" }) - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 @@ -2359,11 +2171,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*" }) filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 @@ -2386,17 +2197,15 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt"; type = "text"; perProject = $true }) filesToExclude = @() - filesToRemove = @() } } # Pass empty projects array - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -projects @() + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -projects @() # Behavior: when projects is empty, no per-project entries should be created $filesToInclude | Should -BeNullOrEmpty $filesToExclude | Should -BeNullOrEmpty - $filesToRemove | Should -BeNullOrEmpty } It 'GetFilesToUpdate ignores filesToExclude patterns that do not match any file' { @@ -2406,11 +2215,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt" }) filesToExclude = @(@{ filter = "no-match-*.none" }) - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # All txt files should be included, no files to exclude $filesToInclude | Should -Not -BeNullOrEmpty @@ -2421,7 +2229,6 @@ Describe "GetFilesToUpdate (general files to update logic)" { $filesToInclude[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'test2.txt') $filesToExclude | Should -BeNullOrEmpty - $filesToRemove | Should -BeNullOrEmpty } It 'GetFilesToUpdate duplicates per-project includes for each project including the repository root' { @@ -2435,12 +2242,11 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "perProjectFile.algo"; perProject = $true; destinationFolder = 'custom' }) filesToExclude = @() - filesToRemove = @() } } $projects = @('.', 'ProjectOne') - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -projects $projects + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -projects $projects $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 @@ -2452,7 +2258,6 @@ Describe "GetFilesToUpdate (general files to update logic)" { $filesToInclude.destinationFullPath | Should -Contain $projectDestination $filesToExclude | Should -BeNullOrEmpty - $filesToRemove | Should -BeNullOrEmpty } finally { if (Test-Path $perProjectFile) { @@ -2485,13 +2290,12 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() - filesToRemove = @() } } $projects = @('ProjectA') - $filesWithoutOriginal, $excludesWithoutOriginal, $removesWithoutOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -projects $projects + $filesWithoutOriginal, $excludesWithoutOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -projects $projects $filesWithoutOriginal | Should -Not -BeNullOrEmpty @@ -2505,7 +2309,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { $filesWithoutOriginal.destinationFullPath | Should -Not -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateRepoSettingsFileName)) $filesWithoutOriginal.destinationFullPath | Should -Not -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateProjectSettingsFileName)) - $filesWithOriginal, $excludesWithOriginal, $removesWithOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder -projects $projects + $filesWithOriginal, $excludesWithOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder -projects $projects $filesWithOriginal | Should -Not -BeNullOrEmpty @@ -2515,10 +2319,8 @@ Describe "GetFilesToUpdate (general files to update logic)" { $filesWithOriginal.destinationFullPath | Should -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateProjectSettingsFileName)) $excludesWithoutOriginal | Should -BeNullOrEmpty - $removesWithoutOriginal | Should -BeNullOrEmpty $excludesWithOriginal | Should -BeNullOrEmpty - $removesWithOriginal | Should -BeNullOrEmpty } finally { if (Test-Path $customTemplateFolder) { @@ -2537,11 +2339,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt" }) filesToExclude = @(@{ filter = "test.txt" }) - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # test.txt should not be in filesToInclude $includedTestTxt = $filesToInclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") } @@ -2559,11 +2360,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "*.txt" }) filesToExclude = @(@{ filter = "nonexistent.xyz" }) - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # All txt files should be included $filesToInclude | Should -Not -BeNullOrEmpty @@ -2582,11 +2382,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "test.txt" }, @{ filter = "test.txt"; destinationName = "test.renamed.txt" }) filesToExclude = @(@{ filter = "test.txt" }) - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # test.txt should not be in filesToInclude $filesToInclude | Should -BeNullOrEmpty @@ -2608,11 +2407,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { @{ filter = "test.txt"; destinationFolder = "folder2" } ) filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # Should have two entries for test.txt with different destinations $testTxtFiles = $filesToInclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") } @@ -2621,219 +2419,6 @@ Describe "GetFilesToUpdate (general files to update logic)" { $testTxtFiles[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'folder2/test.txt') } - It 'GetFilesToUpdate filesToRemove resolves from template folder when file exists in both template and base folder' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @() - filesToRemove = @(@{ filter = "test.txt" }) - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - - # filesToRemove should have one entry resolved from the template folder - $filesToRemove | Should -Not -BeNullOrEmpty - $filesToRemove.Count | Should -Be 1 - $filesToRemove[0].sourceFullPath | Should -Be $testTxtFile - $filesToRemove[0].destinationFullPath | Should -Be (Join-Path $baseFolder "test.txt") - $filesToRemove[0].originalSourceFullPath | Should -Be $null - - # filesToInclude should be empty - $filesToInclude | Should -BeNullOrEmpty - } - - It 'GetFilesToUpdate filesToRemove resolves missing template files from base folder' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @() - filesToRemove = @(@{ filter = "test.base.txt" }) - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - - # filesToRemove should have one entry pointing to the base folder file - $filesToRemove | Should -Not -BeNullOrEmpty - $filesToRemove.Count | Should -Be 1 - $filesToRemove[0].sourceFullPath | Should -Be $testBaseTxtFile - $filesToRemove[0].destinationFullPath | Should -Be $testBaseTxtFile - $filesToRemove[0].originalSourceFullPath | Should -Be $null - } - - It 'GetFilesToUpdate filesToRemove resolves template files not in base folder' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "test2.txt" }) - filesToExclude = @() - filesToRemove = @(@{ filter = "test2.txt" }) - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - - # filesToRemove should have one entry resolved from the template folder - $filesToRemove | Should -Not -BeNullOrEmpty - $filesToRemove.Count | Should -Be 1 - $filesToRemove[0].sourceFullPath | Should -Be (Join-Path $templateFolder "test2.txt") - $filesToRemove[0].destinationFullPath | Should -Be (Join-Path $baseFolder "test2.txt") - $filesToRemove[0].originalSourceFullPath | Should -Be $null - - # filesToExclude should be empty - $filesToExclude | Should -BeNullOrEmpty - } - - It 'GetFilesToUpdate filesToRemove excludes matching files from filesToInclude' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.txt" }, @{ filter = "test2.txt" }) - filesToExclude = @() - filesToRemove = @(@{ filter = "test.txt" }, @{ filter = "test2.txt" }) - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - - # filesToInclude should be empty - $filesToInclude | Should -BeNullOrEmpty - } - - It 'GetFilesToUpdate filesToRemove excludes matching files from filesToExclude' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.txt" }) - filesToExclude = @(@{ filter = "test.txt" }) - filesToRemove = @(@{ filter = "test.txt" }) - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - - # filesToExclude should be empty - $filesToExclude | Should -BeNullOrEmpty - } - - It 'GetFilesToUpdate filesToRemove keeps not matching files in filesToInclude' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.txt" }) - filesToExclude = @() - filesToRemove = @(@{ filter = "test.base.txt" }) - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - - # test.txt should still be in filesToInclude - $filesToInclude.SourceFullPath | Should -Contain ( Join-Path $templateFolder "test.txt" ) - $filesToInclude.destinationFullPath | Should -Contain ( Join-Path $baseFolder "test.txt" ) - } - - It 'GetFilesToUpdate filesToRemove keeps not matching files in filesToExclude' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.txt" }) - filesToExclude = @(@{ filter = "test.txt" }) - filesToRemove = @(@{ filter = "test.base.txt" }) - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - - # test.txt should still be in filesToExclude - $filesToExclude.SourceFullPath | Should -Contain ( Join-Path $templateFolder "test.txt" ) - $filesToExclude.destinationFullPath | Should -Contain ( Join-Path $baseFolder "test.txt" ) - } - - It 'GetFilesToUpdate filesToRemove uses destinationFolder and destinationName keys' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @() - filesToRemove = @(@{ filter = "test.txt"; destinationFolder = "subfolder"; destinationName = "testsub.txt" }) - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - - # filesToRemove should have one entry resolved from the template folder with the specified destinationFolder and destinationName - $filesToRemove | Should -Not -BeNullOrEmpty - $filesToRemove.Count | Should -Be 1 - $filesToRemove[0].sourceFullPath | Should -Be $testTxtFile - $filesToRemove[0].destinationFullPath | Should -Be (Join-Path $baseFolder "subfolder/testsub.txt") - $filesToRemove[0].originalSourceFullPath | Should -Be $null - } - - It 'GetFilesToUpdate filesToRemove with perProject expands per project' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @() - filesToRemove = @(@{ filter = "testsub.txt"; destinationFolder = "subfolder"; perProject = $true }) - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -projects @('.', 'project1', 'project2') - - # filesToRemove should have 3 entries: one for root ('.'), one for project1, and one for project2, all pointing to the respective subfolder/testsub.txt - $filesToRemove | Should -Not -BeNullOrEmpty - $filesToRemove.Count | Should -Be 3 - $filesToRemove.destinationFullPath | Should -Contain (Join-Path $baseFolder "subfolder/testsub.txt") - $filesToRemove.destinationFullPath | Should -Contain (Join-Path $baseFolder "project1/subfolder/testsub.txt") - $filesToRemove.destinationFullPath | Should -Contain (Join-Path $baseFolder "project2/subfolder/testsub.txt") - } - - It 'GetFilesToUpdate empty filesToRemove returns empty third element' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @() - filesToRemove = @() - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - - $filesToRemove | Should -BeNullOrEmpty - } - - It 'GetFilesToUpdate unknown filesToRemove returns empty third element' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @() - filesToRemove = @(@{ filter = "*.unknown" }) - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - - $filesToRemove | Should -BeNullOrEmpty - } - It 'GetFilesToUpdate filesToInclude keeps the first entry when two entries collide on the same destination' { $settings = @{ type = "NotPTE" @@ -2841,11 +2426,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "test.ps1"; destinationName = "conflict.txt" }, @{ filter = "test.txt"; destinationName = "conflict.txt" }) filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # Only one entry should be resolved for the colliding destination $conflict = @($filesToInclude | Where-Object { $_.destinationFullPath -eq (Join-Path $baseFolder "conflict.txt") }) @@ -2855,27 +2439,6 @@ Describe "GetFilesToUpdate (general files to update logic)" { $conflict[0].sourceFullPath | Should -Be $testPSFile } - It 'GetFilesToUpdate filesToRemove keeps the first entry when two entries collide on the same destination' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @() - filesToRemove = @(@{ filter = "test.ps1"; destinationName = "conflict.remove.txt" }, @{ filter = "test.txt"; destinationName = "conflict.remove.txt" }) - } - } - - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - - # Only one entry should be resolved for the colliding destination - $conflict = @($filesToRemove | Where-Object { $_.destinationFullPath -eq (Join-Path $baseFolder "conflict.remove.txt") }) - $conflict.Count | Should -Be 1 - - # The first-listed entry should win over the later entry for the same destination - $conflict[0].sourceFullPath | Should -Be $testPSFile - } - It 'GetFilesToUpdate filesToInclude includes original template files missing in template' { $settings = @{ type = "NotPTE" @@ -2883,11 +2446,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "test.original.txt" }) filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder # test.original.txt of original template should be in filesToInclude $testOriginalTemplateTxtFiles = @($filesToInclude | Where-Object { $_.sourceFullPath -eq $testOriginalTemplateTxtFile }) @@ -2905,11 +2467,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "test.original.txt" }) filesToExclude = @(@{ filter = "test.original.txt" }) - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder # test.original.txt of original template should be in filesToExclude $testOriginalTemplateTxtFiles = @($filesToExclude | Where-Object { $_.sourceFullPath -eq $testOriginalTemplateTxtFile }) @@ -2927,11 +2488,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "test.txt" }) filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder # test.txt of template should be in filesToInclude $testTxtFiles = @($filesToInclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") }) @@ -2952,11 +2512,10 @@ Describe "GetFilesToUpdate (general files to update logic)" { customALGoFiles = @{ filesToInclude = @(@{ filter = "test.txt" }) filesToExclude = @(@{ filter = "test.txt" }) - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder # test.txt of template should be in filesToExclude $testTxtFiles = @($filesToExclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") }) @@ -3084,11 +2643,10 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 25 @@ -3098,7 +2656,6 @@ Describe "GetFilesToUpdate (real template)" { # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty - $filesToRemove | Should -BeNullOrEmpty } It 'GetFilesToUpdate defaults filesToInclude takes precedence over repository settings for the same destination' { @@ -3110,11 +2667,10 @@ Describe "GetFilesToUpdate (real template)" { # Redirect a different template file onto the destination of the default AL-Go-Settings.json entry filesToInclude = @(@{ filter = "Test Next Major.settings.json"; sourceFolder = ".github"; destinationFolder = ".github"; destinationName = "$RepoSettingsFileName" }) filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder $repoSettingsDestination = Join-Path 'baseFolder' (Join-Path '.github' $RepoSettingsFileName) $conflict = @($filesToInclude | Where-Object { $_.destinationFullPath -eq $repoSettingsDestination }) @@ -3132,11 +2688,10 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @(@{ filter = "_BuildALGoProject.yaml"; sourceFolder = ".github/workflows" }) - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder # The default exclude entries (PowerPlatform files) and the repository settings' own exclude entry are both applied $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $powerPlatformFiles[0]) @@ -3155,11 +2710,10 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @(@{ filter = [System.IO.Path]::GetFileName($powerPlatformFiles[0]); sourceFolder = [System.IO.Path]::GetDirectoryName($powerPlatformFiles[0]).Replace('\', '/') }) - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder $ppFileSourcePath = Join-Path $realPTETemplateFolder $powerPlatformFiles[0] @($filesToExclude | Where-Object { $_.sourceFullPath -eq $ppFileSourcePath }).Count | Should -Be 1 @@ -3174,11 +2728,10 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 22 @@ -3198,8 +2751,6 @@ Describe "GetFilesToUpdate (real template)" { $filesToExclude[$i].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder $powerPlatformFiles[$i]) $filesToExclude[$i].destinationFullPath | Should -Be (Join-Path 'baseFolder' $powerPlatformFiles[$i]) } - - $filesToRemove | Should -BeNullOrEmpty } It 'Return the correct files when unusedALGoSystemFiles is specified' { @@ -3210,11 +2761,10 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 24 @@ -3224,7 +2774,6 @@ Describe "GetFilesToUpdate (real template)" { $filesToExclude.Count | Should -Be 1 $filesToExclude[0].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder ".github/Test Next Major.settings.json") $filesToExclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' '.github/Test Next Major.settings.json') - $filesToRemove | Should -BeNullOrEmpty } It 'Return the correct files when unusedALGoSystemFiles is specified and no PP solution is present' { @@ -3235,11 +2784,10 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 21 @@ -3252,7 +2800,6 @@ Describe "GetFilesToUpdate (real template)" { $powerPlatformFiles | ForEach-Object { $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $_) } - $filesToRemove | Should -BeNullOrEmpty } It 'Returns the custom template settings files when there is a custom template' { @@ -3263,13 +2810,12 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() - filesToRemove = @() } } $customTemplateFolder = $realPTETemplateFolder $originalTemplateFolder = $realAppSourceAppTemplateFolder - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template $filesToInclude | Should -Not -BeNullOrEmpty @@ -3303,7 +2849,6 @@ Describe "GetFilesToUpdate (real template)" { # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty - $filesToRemove | Should -BeNullOrEmpty } It 'Returns the original template PP files in filesToInclude when there is a custom template without them and powerPlatformSolutionFolder is not empty' { @@ -3314,14 +2859,13 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() - filesToRemove = @() } } # AppSource App is used as custom template because it has no PP workflows, simulating a custom PTE fork that stripped them out $customTemplateFolder = $realAppSourceAppTemplateFolder $originalTemplateFolder = $realPTETemplateFolder - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template $filesToInclude | Should -Not -BeNullOrEmpty $powerPlatformFiles | ForEach-Object { @@ -3331,7 +2875,6 @@ Describe "GetFilesToUpdate (real template)" { # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty - $filesToRemove | Should -BeNullOrEmpty } It 'Returns the original template PP files in filesToExclude when there is a custom template without them and powerPlatformSolutionFolder is empty' { @@ -3342,14 +2885,13 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() - filesToRemove = @() } } # AppSource App is used as custom template because it has no PP workflows, simulating a custom PTE fork that stripped them out $customTemplateFolder = $realAppSourceAppTemplateFolder $originalTemplateFolder = $realPTETemplateFolder - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template $filesToInclude | Should -Not -BeNullOrEmpty $powerPlatformFiles | ForEach-Object { @@ -3364,7 +2906,6 @@ Describe "GetFilesToUpdate (real template)" { } # No files to remove - $filesToRemove | Should -BeNullOrEmpty } It 'GetFilesToUpdate handles AppSource template type correctly' { @@ -3375,11 +2916,10 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realAppSourceAppTemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realAppSourceAppTemplateFolder # PowerPlatform files should be excluded for AppSource App too (same as PTE) $filesToInclude | Should -Not -BeNullOrEmpty @@ -3400,16 +2940,14 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder # No additional files should be excluded due to unusedALGoSystemFiles $ppExcludes = $filesToExclude | Where-Object { $_.sourceFullPath -like "*_BuildPowerPlatformSolution.yaml" -or $_.sourceFullPath -like "*PullPowerPlatformChanges.yaml" -or $_.sourceFullPath -like "*PushPowerPlatformChanges.yaml" } $ppExcludes.Count | Should -Be 3 # Only PP files should be excluded by default - $filesToRemove | Should -BeNullOrEmpty } It 'GetFilesToUpdate marks settings files with correct type' { @@ -3420,11 +2958,10 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -projects @('Project1') + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -projects @('Project1') # Check that settings files have type = 'settings' $repoSettingsFiles = @($filesToInclude | Where-Object { $_.sourceFullPath -like "*$RepoSettingsFileName" -and $_.destinationFullPath -like "*.github*$RepoSettingsFileName" }) @@ -3434,7 +2971,6 @@ Describe "GetFilesToUpdate (real template)" { $projectSettingsFiles = @($filesToInclude | Where-Object { $_.sourceFullPath -like "*$ALGoSettingsFileName" -and $_.destinationFullPath -like "*Project1*.AL-Go*" }) $projectSettingsFiles | Should -Not -BeNullOrEmpty $projectSettingsFiles[0].type | Should -Be 'settings' - $filesToRemove | Should -BeNullOrEmpty } It 'GetFilesToUpdate handles multiple projects correctly' { @@ -3445,12 +2981,11 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @() - filesToRemove = @() } } $projects = @('ProjectA', 'ProjectB', 'ProjectC') - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -projects $projects + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -projects $projects # Each project should have its own settings file $projectASettings = $filesToInclude | Where-Object { $_.destinationFullPath -like "*ProjectA*.AL-Go*" } @@ -3470,11 +3005,10 @@ Describe "GetFilesToUpdate (real template)" { customALGoFiles = @{ filesToInclude = @() filesToExclude = @(@{ filter = "Test Next Major.settings.json" }) - filesToRemove = @() } } - $filesToInclude, $filesToExclude, $filesToRemove = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder # Test Next Major.settings.json should be excluded $testNextMajor = $filesToInclude | Where-Object { $_.sourceFullPath -like "*Test Next Major.settings.json" } diff --git a/e2eTests/scenarios/CustomTemplate/runtest.ps1 b/e2eTests/scenarios/CustomTemplate/runtest.ps1 index 974eb34744..7efaa97e1f 100644 --- a/e2eTests/scenarios/CustomTemplate/runtest.ps1 +++ b/e2eTests/scenarios/CustomTemplate/runtest.ps1 @@ -207,8 +207,6 @@ $optionalCustomFile = Join-Path $templateRepoPath $optionalCustomFileName $optionalCustomFileContent = "This is an optional custom file in the template repository." Set-Content -Path $optionalCustomFile -Value $optionalCustomFileContent -$legacyCustomFileName = 'CustomTemplateFile.Legacy.txt' - # Remove workflow files from template repository $excludedWorkflowFileName = 'DeployReferenceDocumentation.yaml' $excludedWorkflowFileRelativePath = Join-Path '.github/workflows' $excludedWorkflowFileName @@ -226,7 +224,6 @@ $null = Add-PropertiesToJsonFile -path $templateRepoSettingsFile -properties @{ "customALGoFiles" = @{ "filesToInclude" = @( @{ "filter" = $defaultCustomFileName } ) "filesToExclude" = @( @{ "sourceFolder" = ".github/workflows"; "filter" = $excludedWorkflowFileName } ) - "filesToRemove" = @( @{ "filter" = $legacyCustomFileName } ) } } @@ -348,10 +345,6 @@ $cicdYaml.AddCustomJobsToYaml($customJobs, [CustomizationOrigin]::FinalRepositor # save $cicdYaml.Save($cicdWorkflow) -# Add custom files in the final repository -$legacyCustomFileContent = "This is a removed custom file that will be removed in the final repository." -Set-Content -Path (Join-Path (Get-Location) $legacyCustomFileName) -Value $legacyCustomFileContent - # Remove workflow files from final repository Remove-Item -Path (Join-Path (Get-Location) $missingWorkflowFileRelativePath) -Force | Out-Null @@ -408,8 +401,6 @@ Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Get-ContentLF -Path (Join-Path (Get-Location) $defaultCustomFileName) | Should -Be $defaultCustomFileContent.Replace("`r", "").TrimEnd("`n") # Check that optional custom file is NOT present (not in default or template's filesToInclude) (Join-Path (Get-Location) $optionalCustomFileName) | Should -Not -Exist -# Check that legacy custom file is NOT present (in template's filesToRemove) -(Join-Path (Get-Location) $legacyCustomFileName) | Should -Not -Exist # Check that excluded workflow file is NOT present (in default filesToInclude and template's filesToExclude) (Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Not -Exist @@ -454,8 +445,6 @@ Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | # Check that optional custom file is present (in repos's filesToInclude) (Join-Path (Get-Location) $optionalCustomFileName) | Should -Exist Get-ContentLF -Path (Join-Path (Get-Location) $optionalCustomFileName) | Should -Be $optionalCustomFileContent.Replace("`r", "").TrimEnd("`n") -# Check that legacy custom file is NOT present (in template's filesToRemove) -(Join-Path (Get-Location) $legacyCustomFileName) | Should -Not -Exist # Check that excluded workflow file is NOT present (in default filesToInclude and template's filesToExclude) (Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Not -Exist From d412a754b19e2afc75dc2709704a3c64d5058754 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Wed, 29 Jul 2026 16:04:48 +0200 Subject: [PATCH 13/34] Fixed copilot review comment --- e2eTests/scenarios/CustomTemplate/runtest.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2eTests/scenarios/CustomTemplate/runtest.ps1 b/e2eTests/scenarios/CustomTemplate/runtest.ps1 index 7efaa97e1f..2b88e4cd0d 100644 --- a/e2eTests/scenarios/CustomTemplate/runtest.ps1 +++ b/e2eTests/scenarios/CustomTemplate/runtest.ps1 @@ -258,7 +258,7 @@ Get-ContentLF -Path (Join-Path (Get-Location) $optionalCustomFileName) | Should # Check that excluded workflow file is NOT present (in template's filesToExclude) (Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Not -Exist -# Check that missing workflow file is present (in default filesToExclude) +# Check that missing workflow file is present (in default filesToInclude) (Join-Path (Get-Location) $missingWorkflowFileRelativePath) | Should -Exist # Remove missing workflow files from template repository again From 6376e166ac15433e5608489175e5d2d63f19b401 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Wed, 29 Jul 2026 16:13:42 +0200 Subject: [PATCH 14/34] Fixed issues --- .../CheckForUpdates.HelperFunctions.ps1 | 2326 ++++++++--------- e2eTests/scenarios/CustomTemplate/runtest.ps1 | 4 - 2 files changed, 1163 insertions(+), 1167 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 9aca08ea4f..fcafee2666 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -1,1163 +1,1163 @@ -Import-Module (Join-Path $PSScriptRoot '../.Modules/ReadSettings.psm1') -DisableNameChecking -Import-Module (Join-Path $PSScriptRoot '../.Modules/DebugLogHelper.psm1') -DisableNameChecking - -<# -.SYNOPSIS -Downloads a template repository and returns the path to the downloaded folder -.PARAMETER token -The GitHub token / PAT to use for authentication (if the template repository is private/internal) -.PARAMETER templateUrl -The URL to the template repository -.PARAMETER templateSha -The SHA of the template repository (returned by reference if downloadLatest is true) -.PARAMETER downloadLatest -If true, the latest SHA of the template repository will be downloaded -#> -function DownloadTemplateRepository { - Param( - [string] $token, - [string] $templateUrl, - [ref] $templateSha, - [bool] $downloadLatest - ) - - $templateRepositoryUrl = $templateUrl.Split('@')[0] - $templateRepository = $templateRepositoryUrl.Split('/')[-2..-1] -join '/' - - # Use Authenticated API request if possible to avoid the 60 API calls per hour limit - OutputDebug -message "Getting template repository ($templateRepository) with GITHUB_TOKEN" - $headers = GetHeaders -token $env:GITHUB_TOKEN -repository $templateRepository - try { - $response = Invoke-WebRequest -UseBasicParsing -Headers $headers -Method Head -Uri $templateRepositoryUrl - OutputDebug -message ($response | Format-List | Out-String) - } - catch { - # Ignore error - OutputDebug -message "Error getting template repository with GITHUB_TOKEN:" - OutputDebug -message $_ - $response = $null - } - if (-not $response -or $response.StatusCode -ne 200) { - # GITHUB_TOKEN doesn't have access to template repository, must be private/internal - # Get token with read permissions for the template repository - # NOTE that the GitHub app needs to be installed in the template repository for this to work - $headers = GetHeaders -token $token -repository $templateRepository - } - - # Construct API URL - $apiUrl = $templateUrl.Split('@')[0] -replace "^(https:\/\/github\.com\/)(.*)$", "$ENV:GITHUB_API_URL/repos/`$2" - - Write-Host "TemplateUrl: $templateUrl" - Write-Host "TemplateSha: $($templateSha.Value)" - Write-Host "DownloadLatest: $downloadLatest" - - if ($downloadLatest) { - # Get latest commit SHA from the template repository - $templateSha.Value = GetLatestTemplateSha -headers $headers -apiUrl $apiUrl -templateUrl $templateUrl - Write-Host "Latest SHA for $($templateUrl): $($templateSha.Value)" - } - $archiveUrl = "$apiUrl/zipball/$($templateSha.Value)" - Write-Host "Using ArchiveUrl: $archiveUrl" - - # Download template repository - $tempName = Join-Path (GetTemporaryPath) ([Guid]::NewGuid().ToString()) - InvokeWebRequest -Headers $headers -Uri $archiveUrl -OutFile "$tempName.zip" - Expand-7zipArchive -Path "$tempName.zip" -DestinationPath $tempName - Remove-Item -Path "$tempName.zip" - - return $tempName -} - -function GetLatestTemplateSha { - Param( - [hashtable] $headers, - [string] $apiUrl, - [string] $templateUrl - ) - - $branch = $templateUrl.Split('@')[1] - Write-Host "Get latest SHA for $templateUrl" - try { - $branchInfo = (InvokeWebRequest -Headers $headers -Uri "$apiUrl/branches/$branch").Content | ConvertFrom-Json - } catch { - throw "Failed to update AL-Go System Files. Could not get the latest SHA from template ($templateUrl). (Error was $($_.Exception.Message))" - } - return $branchInfo.commit.sha -} - -function ModifyCICDWorkflow { - Param( - [Yaml] $yaml, - [hashtable] $repoSettings - ) - - # The CICD workflow can have a RepoSetting called CICDPushBranches, which will be used to set the branches for the workflow - # Setting the CICDSchedule will disable the push trigger for the CI/CD workflow (unless CICDPushBranches is set) - if ($repoSettings.Keys -contains 'CICDPushBranches') { - $CICDPushBranches = $repoSettings.CICDPushBranches - } - elseif ($repoSettings.Keys -contains $workflowScheduleKey) { - $CICDPushBranches = '' - } - else { - $CICDPushBranches = $defaultCICDPushBranches - } - # update the branches: line with the new branches - if ($CICDPushBranches) { - $yaml.Replace('on:/push:/branches:', "branches: [ '$($CICDPushBranches -join "', '")' ]") - } - else { - $yaml.Replace('on:/push:',@()) - } -} - -function ModifyPullRequestHandlerWorkflow { - Param( - [Yaml] $yaml, - [hashtable] $repoSettings - ) - # The PullRequestHandler workflow can have a RepoSetting called pullRequestTrigger which specifies the trigger to use for Pull Requests - $triggerSection = $yaml.Get('on:/pull') - $triggerSection.content = "$($repoSettings.pullRequestTrigger):" - $yaml.Replace('on:/pull', $triggerSection.Content) - - # The PullRequestHandler workflow can have a RepoSetting called CICDPullRequestBranches, which will be used to set the branches for the workflow - if ($repoSettings.Keys -contains 'CICDPullRequestBranches') { - $CICDPullRequestBranches = $repoSettings.CICDPullRequestBranches - } - else { - $CICDPullRequestBranches = $defaultCICDPullRequestBranches - } - - # update the branches: line with the new branches - $yaml.Replace("on:/$($repoSettings.pullRequestTrigger):/branches:", "branches: [ '$($CICDPullRequestBranches -join "', '")' ]") -} - -function ModifyRunsOnAndShell { - Param( - [Yaml] $yaml, - [hashtable] $repoSettings - ) - - # The default for runs-on is windows-latest and the default for shell is powershell - # The default for GitHubRunner/GitHubRunnerShell is runs-on/shell (unless Ubuntu-latest are selected here, as build jobs cannot run on Ubuntu) - # We do not change runs-on in Update AL-Go System Files and Pull Request Handler workflows - # These workflows will always run on windows-latest (or maybe Ubuntu-latest later) and not follow settings - # Reasons: - # - Update AL-Go System files is needed for changing runs-on - by having non-functioning runners, you might dead-lock yourself - # - Pull Request Handler workflow for security reasons - if ($repoSettings."runs-on" -ne "windows-latest") { - Write-Host "Setting runs-on to [ $($repoSettings."runs-on") ]" - $yaml.ReplaceAll('runs-on: [ windows-latest ]', "runs-on: [ $($repoSettings."runs-on") ]") - } - if ($repoSettings.shell -ne "powershell" -and $repoSettings.shell -ne "pwsh") { - throw "The shell can only be set to powershell or pwsh" - } - if ($repoSettings."runs-on" -eq "ubuntu-latest" -and $repoSettings.shell -eq "powershell") { - throw "The shell cannot be set to powershell when runs-on is ubuntu-latest. Use pwsh instead." - } - Write-Host "Setting shell to $($repoSettings.shell)" - $yaml.ReplaceAll('shell: powershell', "shell: $($repoSettings.shell)") -} - -function ModifyBuildWorkflows { - Param( - [Yaml] $yaml, - [int] $depth, - [bool] $includeBuildPP - ) - - $yaml.Replace('env:/workflowDepth:',"workflowDepth: $depth") - $build = $yaml.Get('jobs:/Build:/') - $buildPP = $yaml.Get('jobs:/BuildPP:/') - $deliver = $yaml.Get('jobs:/Deliver:/') - $deploy = $yaml.Get('jobs:/Deploy:/') - $deployALDoc = $yaml.Get('jobs:/DeployALDoc:/') - $codeAnalysisUpload = $yaml.Get('jobs:/CodeAnalysisUpload:/') - $postProcess = $yaml.Get('jobs:/PostProcess:/') - if (!$build) { - throw "No build job found in the workflow" - } - - # Duplicate the build job for each dependency depth - $newBuild = @() - for($index = 0; $index -lt $depth; $index++) { - # All build job needs to have a dependency on the Initialization job - $needs = @('Initialization') - if ($index -eq 0) { - # First build job needs to have a dependency on the Initialization job only - # Example (depth 1): - # needs: [ Initialization ] - # if: (!failure()) && (!cancelled()) && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 - $if = "if: (!failure()) && (!cancelled()) && fromJson(needs.Initialization.outputs.buildOrderJson)[$index].projectsCount > 0" - } - else { - # Subsequent build jobs needs to have a dependency on all previous build jobs - # Example (depth 2): - # needs: [ Initialization, Build1 ] - # if: (!failure()) && (!cancelled()) && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 - # Another example (depth 3): - # needs: [ Initialization, Build2, Build1 ] - # if: (!failure()) && (!cancelled()) && (needs.Build2.result == 'success' || needs.Build2.result == 'skipped') && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 - $newBuild += @('') - $ifpart = "" - $index..1 | ForEach-Object { - $needs += @("Build$_") - $ifpart += " && (needs.Build$_.result == 'success' || needs.Build$_.result == 'skipped')" - } - $if = "if: (!failure()) && (!cancelled())$ifpart && fromJson(needs.Initialization.outputs.buildOrderJson)[$index].projectsCount > 0" - } - - # Replace the if:, the needs: and the strategy/matrix/project: in the build job with the correct values - $build.Replace('if:', $if) - $build.Replace('needs:', "needs: [ $($needs -join ', ') ]") - $build.Replace('strategy:/matrix:/include:',"include: `${{ fromJson(needs.Initialization.outputs.buildOrderJson)[$index].buildDimensions }}") - - # Last build job is called build, all other build jobs are called build1, build2, etc. - if ($depth -eq ($index + 1)) { - $newBuild += @("Build:") - } - else { - $newBuild += @("Build$($index + 1):") - } - - # Add the content of the calculated build job to the new build job list with an indentation of 2 spaces - $build.content | ForEach-Object { $newBuild += @(" $_") } - } - - # Replace the entire build: job with the new build job list - $yaml.Replace('jobs:/Build:', $newBuild) - - if (!$includeBuildPP -and $buildPP) { - # Remove the BuildPP job from the workflow - [int]$start = 0 - [int]$count = 0 - if ($yaml.Find('jobs:/BuildPP:', [ref] $start, [ref] $count)) { - $yaml.Remove($start, $count+1) - } - } - - $needs += @("Build") - $ifpart += " && (needs.Build.result == 'success' || needs.Build.result == 'skipped')" - if ($includeBuildPP -and $buildPP) { - $needs += @("BuildPP") - $ifpart += " && (needs.BuildPP.result == 'success' || needs.BuildPP.result == 'skipped')" - } - - $postProcessNeeds = $needs - # Modify Deliver and Deploy steps depending on build jobs - if ($deploy) { - $deploy.Replace('needs:', "needs: [ $($needs -join ', ') ]") - $deploy.Replace('if:', "if: (!cancelled())$ifpart && needs.Initialization.outputs.environmentCount > 0") - $yaml.Replace('jobs:/Deploy:/', $deploy.content) - $postProcessNeeds += @('Deploy') - } - if ($deliver) { - $deliver.Replace('needs:', "needs: [ $($needs -join ', ') ]") - $deliver.Replace('if:', "if: (!cancelled())$ifpart && needs.Initialization.outputs.deliveryTargetsJson != '[]'") - $yaml.Replace('jobs:/Deliver:/', $deliver.content) - $postProcessNeeds += @('Deliver') - } - if ($deployALDoc) { - $postProcessNeeds += @('DeployALDoc') - } - if ($codeAnalysisUpload) { - $postProcessNeeds += @('CodeAnalysisUpload') - } - if ($postProcess) { - $postProcess.Replace('needs:', "needs: [ $($postProcessNeeds -join ', ') ]") - $yaml.Replace('jobs:/PostProcess:/', $postProcess.content) - } -} - -function ModifyUpdateALGoSystemFiles { - Param( - [Yaml] $yaml, - [hashtable] $repoSettings - ) - - if($repoSettings.Keys -notcontains 'UpdateALGoSystemFilesEnvironment') { - # If UpdateALGoSystemFilesEnvironment is not set, we don't need to do anything - return - } - - $updateALGoSystemFilesEnvironment = $repoSettings.UpdateALGoSystemFilesEnvironment - Write-Host "Setting 'Update AL-Go System Files' environment to $updateALGoSystemFilesEnvironment" - - # Add or replace the environment: section in the UpdateALGoSystemFiles job - $updateALGoSystemFilesJob = $yaml.Get('jobs:/UpdateALGoSystemFiles:/') - - if(-not $updateALGoSystemFilesJob) { - # Defensively check that the UpdateALGoSystemFiles job exists - throw "No UpdateALGoSystemFiles job found in the workflow" - } - - $environmentSection = $updateALGoSystemFilesJob.Get('environment:') - if($environmentSection) { - # If the environment: section already exists, replace it with the new environment - $updateALGoSystemFilesJob.Replace($environmentSection, "environment: $updateALGoSystemFilesEnvironment") - } - else { - # If the environment: section does not exist, add it - $updateALGoSystemFilesJob.Insert(1, "environment: $updateALGoSystemFilesEnvironment") - } - - $yaml.Replace('jobs:/UpdateALGoSystemFiles:/', $updateALGoSystemFilesJob.content) -} - -function ApplyWorkflowDefaultInputs { - Param( - [Yaml] $yaml, - [hashtable] $repoSettings, - [string] $workflowName - ) - - # Check if workflow_dispatch inputs exist - $workflowDispatch = $yaml.Get('on:/workflow_dispatch:/') - if (-not $workflowDispatch) { - # No workflow_dispatch section, nothing to do - return - } - - $inputs = $workflowDispatch.Get('inputs:/') - if (-not $inputs) { - # No inputs section, nothing to do - return - } - - if ($repoSettings.workflowDefaultInputs.Count -eq 0) { - # No defaults for this workflow - return - } - - # Get workflow_call inputs - $workflowCallInputs = $yaml.Get('on:/workflow_call:/inputs:/') - - # Apply defaults to matching inputs - $workflowDispatchInputsModified = $false - $workflowCallInputsModified = $false - - foreach ($defaultInput in $repoSettings.workflowDefaultInputs) { - # Apply to workflow_dispatch inputs and only update workflow_call if dispatch was modified - if (ApplyWorkflowDefaultInput -workflowName $workflowName -inputs $inputs -defaultInput $defaultInput) { - $workflowDispatchInputsModified = $true - - # Only apply to workflow_call inputs if the workflow_dispatch input was modified - if ($workflowCallInputs) { - if (ApplyWorkflowDefaultInput -workflowName $workflowName -inputs $workflowCallInputs -defaultInput $defaultInput) { - $workflowCallInputsModified = $true - } - } - } - } - - # Update the workflow_dispatch section if modified - if ($workflowDispatchInputsModified) { - $workflowDispatch.Replace('inputs:/', $inputs.content) - $yaml.Replace('on:/workflow_dispatch:/', $workflowDispatch.content) - } - - # Update workflow_call inputs if modified - if ($workflowCallInputsModified) { - $yaml.Replace('on:/workflow_call:/inputs:/', $workflowCallInputs.content) - } -} - -function ApplyWorkflowDefaultInput { - Param( - [string] $workflowName, - [Yaml] $inputs, - [hashtable] $defaultInput - ) - - $inputName = $defaultInput.name - $defaultValue = $defaultInput.value - - # Check if this input exists in the inputs collection - $inputSection = $inputs.Get("$($inputName):/") - if (-not $inputSection) { - # Input is not present in the workflow - return $false - } - - # Get the input type from the YAML if specified - $inputType = $null - $typeStart = 0 - $typeCount = 0 - if ($inputSection.Find('type:', [ref] $typeStart, [ref] $typeCount)) { - $typeLine = $inputSection.content[$typeStart].Trim() - if ($typeLine -match 'type:\s*(.+)') { - $inputType = $matches[1].Trim() - } - } - - # Validate that the value type matches the input type - $validationError = $null - if ($inputType) { - switch ($inputType) { - 'boolean' { - if ($defaultValue -isnot [bool]) { - $validationError = "Workflow '$workflowName', input '$inputName': Expected boolean value, but got $($defaultValue.GetType().Name). Please use `$true or `$false." - } - } - 'number' { - if ($defaultValue -isnot [int] -and $defaultValue -isnot [long] -and $defaultValue -isnot [double]) { - $validationError = "Workflow '$workflowName', input '$inputName': Expected number value, but got $($defaultValue.GetType().Name)." - } - } - 'string' { - if ($defaultValue -isnot [string]) { - $validationError = "Workflow '$workflowName', input '$inputName': Expected string value, but got $($defaultValue.GetType().Name)." - } - } - 'choice' { - # Choice inputs accept strings and must match one of the available options (case-sensitive) - if ($defaultValue -isnot [string]) { - $validationError = "Workflow '$workflowName', input '$inputName': Expected string value for choice input, but got $($defaultValue.GetType().Name)." - } - else { - # Validate that the value is one of the available options - $optionsStart = 0 - $optionsCount = 0 - if ($inputSection.Find('options:', [ref] $optionsStart, [ref] $optionsCount)) { - $availableOptions = @() - # Parse the options from the YAML (they are indented list items starting with "- ") - for ($i = $optionsStart + 1; $i -lt ($optionsStart + $optionsCount); $i++) { - $optionLine = $inputSection.content[$i].Trim() - if ($optionLine -match '^-\s*(.+)$') { - $availableOptions += $matches[1].Trim() - } - } - - if ($availableOptions.Count -gt 0 -and $availableOptions -cnotcontains $defaultValue) { - $validationError = "Workflow '$workflowName', input '$inputName': Value '$defaultValue' is not a valid choice (case-sensitive match required). Available options: $($availableOptions -join ', ')." - } - } - } - } - } - } - else { - # If no type is specified in the workflow, it defaults to string - if ($defaultValue -isnot [string]) { - OutputWarning "Workflow '$workflowName', input '$inputName': No type specified in workflow (defaults to string), but configured value is $($defaultValue.GetType().Name). This may cause issues." - } - } - - if ($validationError) { - throw $validationError - } - - # Convert the default value to the appropriate YAML format - $yamlValue = $defaultValue - if ($defaultValue -is [bool]) { - $yamlValue = $defaultValue.ToString().ToLower() - } - elseif ($defaultValue -is [string]) { - # Quote strings and escape single quotes per YAML spec - $escapedValue = $defaultValue.Replace("'", "''") - $yamlValue = "'$escapedValue'" - } - - # Find and replace the default: line in the input section - $start = 0 - $count = 0 - - if ($inputSection.Find('default:', [ref] $start, [ref] $count)) { - # Replace existing default value - $inputSection.Replace('default:', "default: $yamlValue") - } - else { - # Add default value - find the best place to insert it - # Insert after type, required, or description (whichever comes last) - $insertAfter = -1 - $typeLine = 0 - $typeCount = 0 - $requiredLine = 0 - $requiredCount = 0 - $descLine = 0 - $descCount = 0 - - if ($inputSection.Find('type:', [ref] $typeLine, [ref] $typeCount)) { - $insertAfter = $typeLine + $typeCount - } - if ($inputSection.Find('required:', [ref] $requiredLine, [ref] $requiredCount)) { - if (($requiredLine + $requiredCount) -gt $insertAfter) { - $insertAfter = $requiredLine + $requiredCount - } - } - if ($inputSection.Find('description:', [ref] $descLine, [ref] $descCount)) { - if (($descLine + $descCount) -gt $insertAfter) { - $insertAfter = $descLine + $descCount - } - } - - if ($insertAfter -eq -1) { - # No other properties, insert at position 1 (after the input name) - $insertAfter = 1 - } - - $inputSection.Insert($insertAfter, "default: $yamlValue") - } - - # Update the inputs collection with the modified input section - $inputs.Replace("$($inputName):/", $inputSection.content) - - return $true -} - -function GetWorkflowContentWithChangesFromSettings { - Param( - [string] $srcFile, - [hashtable] $repoSettings, - [int] $depth - ) - - $baseName = [System.IO.Path]::GetFileNameWithoutExtension($srcFile) - $yaml = [Yaml]::Load($srcFile) - $yamlName = $yaml.get('name:') - if ($yamlName) { - $workflowName = $yamlName.content.SubString('name:'.Length).Trim().Trim('''"').Trim() - } - else { - $workflowName = $baseName - } - - $workflowScheduleKey = "WorkflowSchedule" - $workflowConcurrencyKey = "WorkflowConcurrency" - foreach($key in @($workflowScheduleKey,$workflowConcurrencyKey)) { - if ($repoSettings.Keys -contains $key -and ($repoSettings."$key")) { - throw "The $key setting is not allowed in the global repository settings. Please use the workflow specific settings file or conditional settings." - } - } - - # Re-read settings and this time include workflow specific settings - $repoSettings = ReadSettings -buildMode '' -project '' -workflowName $workflowName -userName '' -branchName '' | ConvertTo-HashTable -recurse - - # Old Schedule key is deprecated, but still supported - $oldWorkflowScheduleKey = "$($baseName)Schedule" - if ($repoSettings.Keys -contains $oldWorkflowScheduleKey) { - # DEPRECATION: REPLACE WITH ERROR AFTER October 1st 2025 ---> - if ($repoSettings.Keys -contains $workflowScheduleKey) { - OutputWarning "Both $oldWorkflowScheduleKey and $workflowScheduleKey are defined in the settings file. $oldWorkflowScheduleKey will be ignored. This warning will become an error in the future" - } - else { - Trace-DeprecationWarning -Message "$oldWorkflowScheduleKey is deprecated" -DeprecationTag "_workflow_Schedule" -WillBecomeError - # Convert the old Schedule setting to the new WorkflowSchedule setting - $repoSettings."$workflowScheduleKey" = @{ "cron" = $repoSettings."$oldWorkflowScheduleKey" } - } - # <--- REPLACE WITH ERROR AFTER October 1st 2025 - } - - # Any workflow (except for the PullRequestHandler and reusable workflows (_*)) can have concurrency and schedule defined - if ($baseName -ne "PullRequestHandler" -and $baseName -notlike '_*') { - # Add Schedule and Concurrency settings to the workflow - if ($repoSettings.Keys -contains $workflowScheduleKey) { - if ($repoSettings."$workflowScheduleKey" -isnot [hashtable] -or $repoSettings."$workflowScheduleKey".Keys -notcontains 'cron' -or $repoSettings."$workflowScheduleKey".cron -isnot [string]) { - throw "The $workflowScheduleKey setting must be a structure containing a cron property" - } - # Replace or add the schedule part under the on: key - $yaml.ReplaceOrAdd('on:/', 'schedule:', @("- cron: '$($repoSettings."$workflowScheduleKey".cron)'")) - } - if ($repoSettings.Keys -contains $workflowConcurrencyKey) { - # Replace or add the concurrency part - $yaml.ReplaceOrAdd('', 'concurrency:', $repoSettings."$workflowConcurrencyKey") - } - } - - if ($baseName -eq "CICD") { - ModifyCICDWorkflow -yaml $yaml -repoSettings $repoSettings - } - - if ($baseName -eq "PullRequestHandler") { - ModifyPullRequestHandlerWorkflow -yaml $yaml -repoSettings $repoSettings - } - - $criticalWorkflows = @('UpdateGitHubGoSystemFiles', 'Troubleshooting') - $allowedRunners = @('windows-latest', 'ubuntu-latest') - $modifyRunsOnAndShell = $true - - # Critical workflows may only run on allowed runners (must always be able to run) - if($criticalWorkflows -contains $baseName) { - if($allowedRunners -notcontains $repoSettings."runs-on") { - $modifyRunsOnAndShell = $false - } - } - - if ($modifyRunsOnAndShell) { - ModifyRunsOnAndShell -yaml $yaml -repoSettings $repoSettings - } - - # PullRequestHandler, CICD, Current, NextMinor and NextMajor workflows all include a build step. - # If the dependency depth is higher than 1, we need to add multiple dependent build jobs to the workflow - if ($baseName -eq 'PullRequestHandler' -or $baseName -eq 'CICD' -or $baseName -eq 'Current' -or $baseName -eq 'NextMinor' -or $baseName -eq 'NextMajor') { - $includeBuildPP = $repoSettings.type -eq 'PTE' -and $repoSettings.powerPlatformSolutionFolder -ne '' - ModifyBuildWorkflows -yaml $yaml -depth $depth -includeBuildPP $includeBuildPP - } - - if($baseName -eq 'UpdateGitHubGoSystemFiles') { - ModifyUpdateALGoSystemFiles -yaml $yaml -repoSettings $repoSettings - } - - # Apply workflow input defaults from settings - if ($repoSettings.Keys -contains 'workflowDefaultInputs') { - ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName $workflowName - } - - # combine all the yaml file lines into a single string with LF line endings - $yaml.content -join "`n" -} - -# Using direct AL-Go repo, we need to change the owner to the templateOwner, the repo names to AL-Go and AL-Go/Actions and the branch to templateBranch - -function ReplaceOwnerRepoAndBranch { - Param( - [ref] $srcContent, - [string] $templateOwner, - [string] $templateBranch - ) - - $lines = $srcContent.Value.Split("`n") - - # The Original Owner and Repo in the AL-Go repository are microsoft/AL-Go-Actions, microsoft/AL-Go-PTE and microsoft/AL-Go-AppSource - $originalOwnerAndRepo = @{ - "actionsRepo" = "microsoft/AL-Go-Actions" - "perTenantExtensionRepo" = "microsoft/AL-Go-PTE" - "appSourceAppRepo" = "microsoft/AL-Go-AppSource" - } - # Original branch is always main - $originalBranch = "main" - - # Modify the file to use repository names based on whether or not we are using the direct AL-Go repo - $templateRepos = @{ - "actionsRepo" = "AL-Go/Actions" - "perTenantExtensionRepo" = "AL-Go" - "appSourceAppRepo" = "AL-Go" - } - - # Replace URL's to actions repository first - $regex = "^(.*)https:\/\/raw\.githubusercontent\.com\/microsoft\/AL-Go-Actions\/$originalBranch(.*)$" - $replace = "`${1}https://raw.githubusercontent.com/$($templateOwner)/AL-Go/$($templateBranch)/Actions`${2}" - $lines = $lines | ForEach-Object { $_ -replace $regex, $replace } - - # Replace the owner and repo names in the workflow - "actionsRepo","perTenantExtensionRepo","appSourceAppRepo" | ForEach-Object { - $regex = "^(.*)$($originalOwnerAndRepo."$_")(.*)$originalBranch(.*)$" - $replace = "`${1}$($templateOwner)/$($templateRepos."$_")`${2}$($templateBranch)`${3}" - $lines = $lines | ForEach-Object { $_ -replace $regex, $replace } - } - $srcContent.Value = $lines -join "`n" -} - -function IsDirectALGo { - param ( - [string] $templateUrl - ) - $directALGo = $templateUrl -like 'https://github.com/*/AL-Go@*' - if ($directALGo) { - if ($templateUrl -like 'https://github.com/microsoft/AL-Go@*' -and -not ($templateUrl -like 'https://github.com/microsoft/AL-Go@*/*')) { - throw "You cannot use microsoft/AL-Go as a template repository. Please use microsoft/AL-Go-PTE, microsoft/AL-Go-AppSource or a fork of AL-Go instead." - } - } - return $directALGo -} - -function GetSrcFolder { - Param( - [string] $repoType, - [string] $templateUrl, - [string] $templateFolder, - [string] $srcPath = '' - ) - - if (!$templateUrl) { - return '' - } - if (IsDirectALGo -templateUrl $templateUrl) { - switch ($repoType) { - "PTE" { - $typePath = "Per Tenant Extension" - } - "AppSource App" { - $typePath = "AppSource App" - } - default { - throw "Unknown repository type" - } - } - $path = Join-Path $templateFolder "*/Templates/$typePath/.github/workflows" - } - else { - $path = Join-Path $templateFolder "*/.github/workflows" - } - # Due to this PowerShell bug: https://github.com/PowerShell/PowerShell/issues/6473#issuecomment-375930843 - # We need to resolve the path of a non-hidden folder (.github/workflows) - # and then get the parent folder of the parent folder of that path - $path = Resolve-Path -Path $path -ErrorAction SilentlyContinue - if (!$path) { - throw "No workflows found in the template repository" - } - $path = Join-Path -Path (Split-Path -Path (Split-Path -Path $path -Parent) -Parent) -ChildPath $srcPath - return $path -} - -function GetModifiedSettingsContent { - Param( - [string] $srcSettingsFile, - [string] $dstSettingsFile - ) - - $srcSettings = Get-ContentLF -Path $srcSettingsFile | ConvertFrom-Json - - $dstSettings = $null - if(Test-Path -Path $dstSettingsFile -PathType Leaf) { - $dstSettings = Get-ContentLF -Path $dstSettingsFile | ConvertFrom-Json - } - - if(!$dstSettings) { - # If the destination settings file does not exist or it's empty, create an new settings object with default values from the source settings (which includes the $schema property already) - $dstSettings = $srcSettings - } - else { - # Change the $schema property to be the same as the source settings file (add it if it doesn't exist) - $schemaKey = '$schema' - if ($srcSettings.PSObject.Properties.Name -eq $schemaKey) { - $schemaValue = $srcSettings."$schemaKey" - - $dstSettings | Add-Member -MemberType NoteProperty -Name "$schemaKey" -Value $schemaValue -Force - - # Make sure the $schema property is the first property in the object - # PS5-safe: explicitly list properties instead of using wildcard to avoid literal '*' being added - $otherProperties = @($dstSettings.PSObject.Properties.Name | Where-Object { $_ -ne $schemaKey }) - $selectProperties = @($schemaKey) + $otherProperties - $dstSettings = $dstSettings | Select-Object -Property $selectProperties - } - } - - return $dstSettings | ConvertTo-JsonLF -} - -function UpdateSettingsFile { - Param( - [string] $settingsFile, - [hashtable] $updateSettings - ) - - $modified = $false - # Update Repo Settings file with the template URL - if (Test-Path $settingsFile) { - $settings = Get-Content $settingsFile -Encoding UTF8 | ConvertFrom-Json - } - else { - $settings = [PSCustomObject]@{} - $modified = $true - } - foreach($key in $updateSettings.Keys) { - if ($settings.PSObject.Properties.Name -eq $key) { - if ($settings."$key" -ne $updateSettings."$key") { - $settings."$key" = $updateSettings."$key" - $modified = $true - } - } - else { - # Add the property if it doesn't exist - $settings | Add-Member -MemberType NoteProperty -Name "$key" -Value $updateSettings."$key" - $modified = $true - } - } - if ($modified) { - # Save the file with LF line endings and UTF8 encoding - $settings | Set-JsonContentLF -path $settingsFile - } - return $modified -} - -<# -.SYNOPSIS -Resolves file paths based on the provided source folder, destination folder, and file specifications. - -.DESCRIPTION -This function takes a source folder, an optional original source folder, a destination folder, and an array of file specifications. It resolves the full paths for each specified file, considering their origin (template or custom template), type, and whether they are per-project files. -The function returns an array of hashtables containing the resolved source and destination file paths. -The function is used to determine which files need to be copied from the template repository to the target repository during the AL-Go update process. -sourceFolder: The base folder of the template used to resolve the source file paths. -originalSourceFolder: The base folder of the original template used to check for original files (can be $null). This is in the case of custom templates, where if the file exists in the original template, it should be used instead of the custom template file. -destinationFolder: The base folder used to construct the destination file paths. This is typically the root folder of the target repository. - -.PARAMETER sourceFolder -The base folder where the source files are located. - -.PARAMETER originalSourceFolder -The original source folder to check for original files (can be $null). All files of origin 'custom template' are skipped if this parameter is $null. - -.PARAMETER destinationFolder -The base folder used to construct the destination file paths. - -.PARAMETER files -An array of hashtables specifying the files to resolve. Each hashtable can contain the following keys: -- sourceFolder: The subfolder within the source folder to search for files (default is current folder). -- filter: The file filter to apply when searching for files (default is all files). -- origin: The origin of the files, either 'template' or 'custom template' (default is 'template'). -- type: The type of the files (default is empty). -- destinationFolder: The subfolder within the destination folder where the files should be placed (default is the same as sourceFolder). -- perProject: A boolean indicating whether the files are per project (default is false). - -.PARAMETER projects -An array of project names used when resolving per-project file paths. - -.OUTPUTS -An array of hashtables, each containing: -- sourceFullPath: The full path to the source file. -- originalSourceFullPath: The full path to the original source file (if found, otherwise $null). -- type: The type of the file. -- destinationFullPath: The full path to the destination file. -#> -function ResolveFilePaths { - Param( - [Parameter(Mandatory=$true)] - [string] $sourceFolder, - [string] $originalSourceFolder = $null, - [Parameter(Mandatory=$true)] - [string] $destinationFolder, - [array] $files = @(), - [string[]] $projects = @() - ) - - if(-not $files) { - return @() - } - - $sourceFolder = Join-Path $sourceFolder '' # Ensure source folder has a trailing slash for correct path resolution - - $fullFilePaths = @() - foreach($file in $files) { - if($file.Keys -notcontains 'sourceFolder') { - $file.sourceFolder = '' # Default to current folder - } - - if($file.Keys -notcontains 'filter') { - $file.filter = '' # Default to all files - } - - if($file.Keys -notcontains 'origin') { - $file.origin = 'template' # Default to template - } - - if($file.Keys -notcontains 'type') { - $file.type = '' # Default to empty - } - - if($file.Keys -notcontains 'destinationFolder') { - # If destinationFolder is not specified, use the sourceFolder, so that the file structure is preserved - $file.destinationFolder = $file.sourceFolder - } - - if($file.Keys -notcontains 'perProject') { - $file.perProject = $false # Default to false - } - - # If originalSourceFolder is not specified, it means there is no custom template, so skip custom template files - if(!$originalSourceFolder -and $file.origin -eq 'custom template') { - OutputDebug "Skipping custom template file(s) with source folder '$($file.sourceFolder)' as there is no original source folder specified" - continue; - } - - # All files are relative to the template folder - OutputDebug "Resolving files for source folder '$($file.sourceFolder)' and filter '$($file.filter)'" - $sourceFiles = @(Get-ChildItem -Path (Join-Path $sourceFolder $file.sourceFolder) -Filter $file.filter -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName) - - OutputDebug "Found $($sourceFiles.Count) files for filter '$($file.filter)' in folder '$($file.sourceFolder)' (relative to folder '$sourceFolder', origin '$($file.origin)')" - - if(-not $sourceFiles) { - OutputDebug "No files found for filter '$($file.filter)' in folder '$($file.sourceFolder)' (relative to folder '$sourceFolder')" - continue - } - - foreach($srcFile in $sourceFiles) { - $fullFilePath = @{ - 'sourceFullPath' = $srcFile - 'originalSourceFullPath' = $null - 'type' = $file.type - 'destinationFullPath' = $null - } - - # Check if the source file is under the source folder - if ($srcFile -notlike "$sourceFolder*") { - OutputDebug "Skipping source file '$($srcFile)' as it is not under the source folder '$($sourceFolder)'." - continue - } - - OutputDebug "Processing file '$($srcFile)'" - - # Try to find the same files in the original template folder if it is specified. Exclude custom template files - if ($originalSourceFolder -and ($file.origin -ne 'custom template')) { - Push-Location $sourceFolder - $relativePath = Resolve-Path -Path $srcFile -Relative # resolve the path relative to the current location (template folder) - Pop-Location - if (Test-Path (Join-Path $originalSourceFolder $relativePath) -PathType Leaf) { - # If the file exists in the original template folder, use that file instead - $fullFilePath.originalSourceFullPath = Join-Path $originalSourceFolder $relativePath -Resolve - } - } - - $destinationName = Split-Path -Path $srcFile -Leaf - if($file.Keys -contains 'destinationName' -and ($file.destinationName)) { - $destinationName = $file.destinationName - } - - if($file.Keys -contains 'perProject' -and $file.perProject -eq $true) { - # Multiple file entries, one for each project - # Destination full path is the destination base folder + project + destinationFolder + destinationName - - foreach($project in $projects) { - if($project -eq '.') { - $project = '' # If project is '.', it means the root folder, so we use an empty string - } - - $fullProjectFilePath = $fullFilePath.Clone() - - $fullProjectFilePath.destinationFullPath = Join-Path $destinationFolder $project - $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $file.destinationFolder - $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $destinationName - - if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullProjectFilePath.destinationFullPath) { - OutputDebug "Skipping duplicate per-project file for project '$project': destinationFullPath '$($fullProjectFilePath.destinationFullPath)' already exists" - continue - } - OutputDebug "Adding per-project file for project '$project': sourceFullPath '$($fullProjectFilePath.sourceFullPath)', originalSourceFullPath '$($fullProjectFilePath.originalSourceFullPath)', destinationFullPath '$($fullProjectFilePath.destinationFullPath)'" - $fullFilePaths += $fullProjectFilePath - } - } - else { - # Single file entry - # Destination full path is the destination base folder + destinationFolder + destinationName - - $fullFilePath.destinationFullPath = Join-Path $destinationFolder $file.destinationFolder - $fullFilePath.destinationFullPath = Join-Path $fullFilePath.destinationFullPath $destinationName - - if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullFilePath.destinationFullPath) { - OutputDebug "Skipping duplicate file: destinationFullPath '$($fullFilePath.destinationFullPath)' already exists" - continue - } - OutputDebug "Adding file: sourceFullPath '$($fullFilePath.sourceFullPath)', originalSourceFullPath '$($fullFilePath.originalSourceFullPath)', destinationFullPath '$($fullFilePath.destinationFullPath)'" - $fullFilePaths += $fullFilePath - } - } - } - - return @($fullFilePaths) -} - -function GetDefaultFilesToInclude { - Param( - [switch] $includeCustomTemplateFiles - ) - - $filesToInclude = @( - [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = '*.yaml'; 'type' = 'workflow' } - [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = '*.yml'; 'type' = 'workflow' } - [ordered]@{ 'sourceFolder' = '.github'; 'filter' = '*.copy.md' } - [ordered]@{ 'sourceFolder' = '.github'; 'filter' = '*.ps1' } - [ordered]@{ 'sourceFolder' = '.github'; 'filter' = "$RepoSettingsFileName"; 'type' = 'settings' } - [ordered]@{ 'sourceFolder' = '.github'; 'filter' = '*.settings.json'; 'type' = 'settings' } - [ordered]@{ 'sourceFolder' = '.github/.agents'; 'filter' = '*.agent.md' } - - [ordered]@{ 'sourceFolder' = '.AL-Go'; 'filter' = '*.ps1'; 'perProject' = $true }, - [ordered]@{ 'sourceFolder' = '.AL-Go'; 'filter' = "$ALGoSettingsFileName"; 'perProject' = $true; 'type' = 'settings' } - ) - - if($includeCustomTemplateFiles) { - # If there is an original template folder, we also need to include custom template files (RepoSettings and ProjectSettings) - $filesToInclude += @( - [ordered]@{ 'sourceFolder' = ".github"; 'filter' = "$RepoSettingsFileName"; 'destinationName' = "$CustomTemplateRepoSettingsFileName"; 'origin' = 'custom template' } - [ordered]@{ 'sourceFolder' = ".AL-Go"; 'filter' = "$ALGoSettingsFileName"; 'destinationFolder' = '.github'; 'destinationName' = "$CustomTemplateProjectSettingsFileName"; 'origin' = 'custom template' } - ) - } - - return $filesToInclude -} - -function GetDefaultFilesToExclude { - Param( - $settings - ) - $filesToExclude = @() - - $includeBuildPP = $settings.type -eq 'PTE' -and $settings.powerPlatformSolutionFolder -ne '' - if (!$includeBuildPP) { - # Remove PowerPlatform workflows if no PowerPlatformSolution exists - $filesToExclude += @( - [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = '_BuildPowerPlatformSolution.yaml' } - [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = 'PushPowerPlatformChanges.yaml' } - [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = 'PullPowerPlatformChanges.yaml' } - ) - } - - return @($filesToExclude) -} - -<# -.SYNOPSIS - Reads settings using the current custom template repository settings without changing the workspace. -.DESCRIPTION - Temporarily refreshes the custom template repository settings snapshot, reads the merged settings, and restores - the snapshot to its original state. This allows the current template settings to affect the current run while - preserving the workspace state for the normal update comparison. -.PARAMETER baseFolder - The base folder of the repository whose settings are read. -.PARAMETER templateFolder - The folder where the custom template files are located. -#> -function ReadSettingsWithCurrentCustomTemplateRepoSettings { - Param( - [Parameter(Mandatory=$true)] - [string] $baseFolder, - [Parameter(Mandatory=$true)] - [string] $templateFolder - ) - - $templateFolderRepoSettingsPath = Join-Path $templateFolder $RepoSettingsFile - - $baseFolderTemplateSettingsPath = Join-Path $baseFolder $CustomTemplateRepoSettingsFile - $baseFolderTemplateSettingsBackupPath = $null - - if (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { - $baseFolderTemplateSettingsBackupPath = Join-Path (GetTemporaryPath) ([Guid]::NewGuid().ToString()) - Copy-Item -LiteralPath $baseFolderTemplateSettingsPath -Destination $baseFolderTemplateSettingsBackupPath -Force - } - - try { - if (Test-Path -LiteralPath $templateFolderRepoSettingsPath -PathType Leaf) { - Copy-Item -LiteralPath $templateFolderRepoSettingsPath -Destination $baseFolderTemplateSettingsPath -Force - } - return ReadSettings -baseFolder $baseFolder -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse - } - finally { - if ($baseFolderTemplateSettingsBackupPath) { - Copy-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Destination $baseFolderTemplateSettingsPath -Force - Remove-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Force - } - elseif (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { - Remove-Item -LiteralPath $baseFolderTemplateSettingsPath -Force - } - } -} - -<# -.SYNOPSIS - Get the list of files from the template repository to include and exclude based on the provided settings. -.DESCRIPTION - Builds two lists by merging defaults, repository settings, and the original AL-Go template (if given): - - 1. filesToInclude: Files to copy from the template or original template to the destination. - Built from default files to include and customALGoFiles.filesToInclude in settings, resolved against the template folder and original template folder (if any). - 2. filesToExclude: Files to skip from copying; if they already exist in the destination they should be deleted. - Built from default files to exclude and customALGoFiles.filesToExclude in settings, resolved against the template folder and original template folder (if any). - - Note: when a custom template is in use, the caller is expected to call - ReadSettingsWithCurrentCustomTemplateRepoSettings before this function, so that the template's - customALGoFiles/unusedALGoSystemFiles are already merged into settings. - - The deprecated unusedALGoSystemFiles setting is also applied: matching files are moved from filesToInclude to - filesToExclude with a deprecation warning. -.PARAMETER settings - The settings object containing the customALGoFiles configuration. -.PARAMETER baseFolder - The base folder of the repository. This is the target folder where the files will be updated. -.PARAMETER templateFolder - The folder where the template files are located. -.PARAMETER originalTemplateFolder - The folder where the original AL-Go template files are located (if any). - When provided, it signals that a custom template is in use. Both filesToInclude and filesToExclude specs are - resolved against this folder in addition to templateFolder; entries not already covered by originalSourceFullPath - tracking are appended to propagate upstream template additions and deletions to consumer repositories. -.PARAMETER projects - The list of projects in the repository. - The projects are used to resolve per-project files. -.OUTPUTS - An array containing two elements: the list of files to include and the list of files to exclude. - Files are represented as hashtables with the following keys: - - sourceFullPath: The full path to the source file. - - originalSourceFullPath: The full path to the original source file in the original template repository (if any). - - type: The type of the file (e.g., workflow, settings). - - destinationFullPath: The full path to the destination file in the target repository. -#> -function GetFilesToUpdate { - Param( - [Parameter(Mandatory=$true)] - $settings, - [Parameter(Mandatory=$true)] - $baseFolder, - [Parameter(Mandatory=$true)] - $templateFolder, - $originalTemplateFolder = $null, - $projects = @() - ) - - $hasOriginalTemplate = $null -ne $originalTemplateFolder - Write-Host "Getting files to update from template folder '$templateFolder', original template folder '$originalTemplateFolder' and base folder '$baseFolder'" - - # Send telemetery about customALGoFiles usage - if ($settings.customALGoFiles.filesToInclude.Count -gt 0) { - Trace-Information -Message "Usage: Custom AL-Go Files (Include)" - } - if ($settings.customALGoFiles.filesToExclude.Count -gt 0) { - Trace-Information -Message "Usage: Custom AL-Go Files (Exclude)" - } - # Determine files to include - $filesToIncludeUnresolved = GetDefaultFilesToInclude -includeCustomTemplateFiles:$hasOriginalTemplate - $filesToIncludeUnresolved += $settings.customALGoFiles.filesToInclude - $filesToInclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) - if ($hasOriginalTemplate) { - $filesToInclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) - } - # Deduplicate files to include based on destinationFullPath, keeping the first one (default > settings; template folder > original template folder) - $filesToInclude = @($filesToInclude | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[0] }) - - # Determine files to exclude - $filesToExcludeUnresolved = GetDefaultFilesToExclude -settings $settings - $filesToExcludeUnresolved += $settings.customALGoFiles.filesToExclude - $filesToExclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) - if ($hasOriginalTemplate) { - $filesToExclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) - } - # filesToExclude is not deduplicated by destinationFullPath here. - # Its destinationFullPath is never part of the actual output; only sourceFullPath is used below to match against filesToInclude. - - # Map files from filesToExclude to files that are in filesToInclude (based on source) - # Settings for filesToExclude only define the sources (sourceFolder and filter) but not the destinations (destinationFolder, destinationName and perProject) - $filesToExclude = @($filesToInclude | Where-Object { - $fileToInclude = $_ - return $filesToExclude | Where-Object { $_.sourceFullPath -eq $fileToInclude.sourceFullPath } - }) - - # Exclude files from filesToInclude that are in filesToExclude (based on source) - $filesToInclude = @($filesToInclude | Where-Object { - $file = $_ - $include = -not ($filesToExclude | Where-Object { $_.sourceFullPath -eq $file.sourceFullPath }) - if (-not $include) { OutputDebug "Excluding source file '$($file.sourceFullPath)' from include list as it is in the exclude list" } - return $include - }) - - # Apply unusedALGoSystemFiles logic - $unusedALGoSystemFiles = $settings.unusedALGoSystemFiles - - # Exclude unusedALGoSystemFiles from $filesToInclude and add them to $filesToExclude - $unusedFilesToExclude = $filesToInclude | Where-Object { $unusedALGoSystemFiles -contains (Split-Path -Path $_.sourceFullPath -Leaf) } - if ($unusedFilesToExclude) { - Trace-DeprecationWarning "The 'unusedALGoSystemFiles' setting is deprecated and will be removed in future versions." -DeprecationTag "unusedALGoSystemFiles" - - OutputDebug "The following files are marked as unused and will be removed if they exist:" - $unusedFilesToExclude | ForEach-Object { OutputDebug "- $($_.destinationFullPath)" } - - $filesToInclude = @($filesToInclude | Where-Object { $unusedALGoSystemFiles -notcontains (Split-Path -Path $_.sourceFullPath -Leaf) }) - $filesToExclude += @($unusedFilesToExclude) - } - - # List all files to be included and excluded with their source and destination paths, type and original source path (if any) - $fileFormatter = { param($file) " -Source: $($file.sourceFullPath), Destination: $($file.destinationFullPath), Type: $($file.type), Original Source: $($file.originalSourceFullPath)"} - OutputArray -Message "Files to include: $($filesToInclude.Count)" -Array $filesToInclude -Formatter $fileFormatter - OutputArray -Message "Files to exclude: $($filesToExclude.Count)" -Array $filesToExclude -Formatter $fileFormatter - - return @($filesToInclude), @($filesToExclude) -} +Import-Module (Join-Path $PSScriptRoot '../.Modules/ReadSettings.psm1') -DisableNameChecking +Import-Module (Join-Path $PSScriptRoot '../.Modules/DebugLogHelper.psm1') -DisableNameChecking + +<# +.SYNOPSIS +Downloads a template repository and returns the path to the downloaded folder +.PARAMETER token +The GitHub token / PAT to use for authentication (if the template repository is private/internal) +.PARAMETER templateUrl +The URL to the template repository +.PARAMETER templateSha +The SHA of the template repository (returned by reference if downloadLatest is true) +.PARAMETER downloadLatest +If true, the latest SHA of the template repository will be downloaded +#> +function DownloadTemplateRepository { + Param( + [string] $token, + [string] $templateUrl, + [ref] $templateSha, + [bool] $downloadLatest + ) + + $templateRepositoryUrl = $templateUrl.Split('@')[0] + $templateRepository = $templateRepositoryUrl.Split('/')[-2..-1] -join '/' + + # Use Authenticated API request if possible to avoid the 60 API calls per hour limit + OutputDebug -message "Getting template repository ($templateRepository) with GITHUB_TOKEN" + $headers = GetHeaders -token $env:GITHUB_TOKEN -repository $templateRepository + try { + $response = Invoke-WebRequest -UseBasicParsing -Headers $headers -Method Head -Uri $templateRepositoryUrl + OutputDebug -message ($response | Format-List | Out-String) + } + catch { + # Ignore error + OutputDebug -message "Error getting template repository with GITHUB_TOKEN:" + OutputDebug -message $_ + $response = $null + } + if (-not $response -or $response.StatusCode -ne 200) { + # GITHUB_TOKEN doesn't have access to template repository, must be private/internal + # Get token with read permissions for the template repository + # NOTE that the GitHub app needs to be installed in the template repository for this to work + $headers = GetHeaders -token $token -repository $templateRepository + } + + # Construct API URL + $apiUrl = $templateUrl.Split('@')[0] -replace "^(https:\/\/github\.com\/)(.*)$", "$ENV:GITHUB_API_URL/repos/`$2" + + Write-Host "TemplateUrl: $templateUrl" + Write-Host "TemplateSha: $($templateSha.Value)" + Write-Host "DownloadLatest: $downloadLatest" + + if ($downloadLatest) { + # Get latest commit SHA from the template repository + $templateSha.Value = GetLatestTemplateSha -headers $headers -apiUrl $apiUrl -templateUrl $templateUrl + Write-Host "Latest SHA for $($templateUrl): $($templateSha.Value)" + } + $archiveUrl = "$apiUrl/zipball/$($templateSha.Value)" + Write-Host "Using ArchiveUrl: $archiveUrl" + + # Download template repository + $tempName = Join-Path (GetTemporaryPath) ([Guid]::NewGuid().ToString()) + InvokeWebRequest -Headers $headers -Uri $archiveUrl -OutFile "$tempName.zip" + Expand-7zipArchive -Path "$tempName.zip" -DestinationPath $tempName + Remove-Item -Path "$tempName.zip" + + return $tempName +} + +function GetLatestTemplateSha { + Param( + [hashtable] $headers, + [string] $apiUrl, + [string] $templateUrl + ) + + $branch = $templateUrl.Split('@')[1] + Write-Host "Get latest SHA for $templateUrl" + try { + $branchInfo = (InvokeWebRequest -Headers $headers -Uri "$apiUrl/branches/$branch").Content | ConvertFrom-Json + } catch { + throw "Failed to update AL-Go System Files. Could not get the latest SHA from template ($templateUrl). (Error was $($_.Exception.Message))" + } + return $branchInfo.commit.sha +} + +function ModifyCICDWorkflow { + Param( + [Yaml] $yaml, + [hashtable] $repoSettings + ) + + # The CICD workflow can have a RepoSetting called CICDPushBranches, which will be used to set the branches for the workflow + # Setting the CICDSchedule will disable the push trigger for the CI/CD workflow (unless CICDPushBranches is set) + if ($repoSettings.Keys -contains 'CICDPushBranches') { + $CICDPushBranches = $repoSettings.CICDPushBranches + } + elseif ($repoSettings.Keys -contains $workflowScheduleKey) { + $CICDPushBranches = '' + } + else { + $CICDPushBranches = $defaultCICDPushBranches + } + # update the branches: line with the new branches + if ($CICDPushBranches) { + $yaml.Replace('on:/push:/branches:', "branches: [ '$($CICDPushBranches -join "', '")' ]") + } + else { + $yaml.Replace('on:/push:',@()) + } +} + +function ModifyPullRequestHandlerWorkflow { + Param( + [Yaml] $yaml, + [hashtable] $repoSettings + ) + # The PullRequestHandler workflow can have a RepoSetting called pullRequestTrigger which specifies the trigger to use for Pull Requests + $triggerSection = $yaml.Get('on:/pull') + $triggerSection.content = "$($repoSettings.pullRequestTrigger):" + $yaml.Replace('on:/pull', $triggerSection.Content) + + # The PullRequestHandler workflow can have a RepoSetting called CICDPullRequestBranches, which will be used to set the branches for the workflow + if ($repoSettings.Keys -contains 'CICDPullRequestBranches') { + $CICDPullRequestBranches = $repoSettings.CICDPullRequestBranches + } + else { + $CICDPullRequestBranches = $defaultCICDPullRequestBranches + } + + # update the branches: line with the new branches + $yaml.Replace("on:/$($repoSettings.pullRequestTrigger):/branches:", "branches: [ '$($CICDPullRequestBranches -join "', '")' ]") +} + +function ModifyRunsOnAndShell { + Param( + [Yaml] $yaml, + [hashtable] $repoSettings + ) + + # The default for runs-on is windows-latest and the default for shell is powershell + # The default for GitHubRunner/GitHubRunnerShell is runs-on/shell (unless Ubuntu-latest are selected here, as build jobs cannot run on Ubuntu) + # We do not change runs-on in Update AL-Go System Files and Pull Request Handler workflows + # These workflows will always run on windows-latest (or maybe Ubuntu-latest later) and not follow settings + # Reasons: + # - Update AL-Go System files is needed for changing runs-on - by having non-functioning runners, you might dead-lock yourself + # - Pull Request Handler workflow for security reasons + if ($repoSettings."runs-on" -ne "windows-latest") { + Write-Host "Setting runs-on to [ $($repoSettings."runs-on") ]" + $yaml.ReplaceAll('runs-on: [ windows-latest ]', "runs-on: [ $($repoSettings."runs-on") ]") + } + if ($repoSettings.shell -ne "powershell" -and $repoSettings.shell -ne "pwsh") { + throw "The shell can only be set to powershell or pwsh" + } + if ($repoSettings."runs-on" -eq "ubuntu-latest" -and $repoSettings.shell -eq "powershell") { + throw "The shell cannot be set to powershell when runs-on is ubuntu-latest. Use pwsh instead." + } + Write-Host "Setting shell to $($repoSettings.shell)" + $yaml.ReplaceAll('shell: powershell', "shell: $($repoSettings.shell)") +} + +function ModifyBuildWorkflows { + Param( + [Yaml] $yaml, + [int] $depth, + [bool] $includeBuildPP + ) + + $yaml.Replace('env:/workflowDepth:',"workflowDepth: $depth") + $build = $yaml.Get('jobs:/Build:/') + $buildPP = $yaml.Get('jobs:/BuildPP:/') + $deliver = $yaml.Get('jobs:/Deliver:/') + $deploy = $yaml.Get('jobs:/Deploy:/') + $deployALDoc = $yaml.Get('jobs:/DeployALDoc:/') + $codeAnalysisUpload = $yaml.Get('jobs:/CodeAnalysisUpload:/') + $postProcess = $yaml.Get('jobs:/PostProcess:/') + if (!$build) { + throw "No build job found in the workflow" + } + + # Duplicate the build job for each dependency depth + $newBuild = @() + for($index = 0; $index -lt $depth; $index++) { + # All build job needs to have a dependency on the Initialization job + $needs = @('Initialization') + if ($index -eq 0) { + # First build job needs to have a dependency on the Initialization job only + # Example (depth 1): + # needs: [ Initialization ] + # if: (!failure()) && (!cancelled()) && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 + $if = "if: (!failure()) && (!cancelled()) && fromJson(needs.Initialization.outputs.buildOrderJson)[$index].projectsCount > 0" + } + else { + # Subsequent build jobs needs to have a dependency on all previous build jobs + # Example (depth 2): + # needs: [ Initialization, Build1 ] + # if: (!failure()) && (!cancelled()) && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 + # Another example (depth 3): + # needs: [ Initialization, Build2, Build1 ] + # if: (!failure()) && (!cancelled()) && (needs.Build2.result == 'success' || needs.Build2.result == 'skipped') && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 + $newBuild += @('') + $ifpart = "" + $index..1 | ForEach-Object { + $needs += @("Build$_") + $ifpart += " && (needs.Build$_.result == 'success' || needs.Build$_.result == 'skipped')" + } + $if = "if: (!failure()) && (!cancelled())$ifpart && fromJson(needs.Initialization.outputs.buildOrderJson)[$index].projectsCount > 0" + } + + # Replace the if:, the needs: and the strategy/matrix/project: in the build job with the correct values + $build.Replace('if:', $if) + $build.Replace('needs:', "needs: [ $($needs -join ', ') ]") + $build.Replace('strategy:/matrix:/include:',"include: `${{ fromJson(needs.Initialization.outputs.buildOrderJson)[$index].buildDimensions }}") + + # Last build job is called build, all other build jobs are called build1, build2, etc. + if ($depth -eq ($index + 1)) { + $newBuild += @("Build:") + } + else { + $newBuild += @("Build$($index + 1):") + } + + # Add the content of the calculated build job to the new build job list with an indentation of 2 spaces + $build.content | ForEach-Object { $newBuild += @(" $_") } + } + + # Replace the entire build: job with the new build job list + $yaml.Replace('jobs:/Build:', $newBuild) + + if (!$includeBuildPP -and $buildPP) { + # Remove the BuildPP job from the workflow + [int]$start = 0 + [int]$count = 0 + if ($yaml.Find('jobs:/BuildPP:', [ref] $start, [ref] $count)) { + $yaml.Remove($start, $count+1) + } + } + + $needs += @("Build") + $ifpart += " && (needs.Build.result == 'success' || needs.Build.result == 'skipped')" + if ($includeBuildPP -and $buildPP) { + $needs += @("BuildPP") + $ifpart += " && (needs.BuildPP.result == 'success' || needs.BuildPP.result == 'skipped')" + } + + $postProcessNeeds = $needs + # Modify Deliver and Deploy steps depending on build jobs + if ($deploy) { + $deploy.Replace('needs:', "needs: [ $($needs -join ', ') ]") + $deploy.Replace('if:', "if: (!cancelled())$ifpart && needs.Initialization.outputs.environmentCount > 0") + $yaml.Replace('jobs:/Deploy:/', $deploy.content) + $postProcessNeeds += @('Deploy') + } + if ($deliver) { + $deliver.Replace('needs:', "needs: [ $($needs -join ', ') ]") + $deliver.Replace('if:', "if: (!cancelled())$ifpart && needs.Initialization.outputs.deliveryTargetsJson != '[]'") + $yaml.Replace('jobs:/Deliver:/', $deliver.content) + $postProcessNeeds += @('Deliver') + } + if ($deployALDoc) { + $postProcessNeeds += @('DeployALDoc') + } + if ($codeAnalysisUpload) { + $postProcessNeeds += @('CodeAnalysisUpload') + } + if ($postProcess) { + $postProcess.Replace('needs:', "needs: [ $($postProcessNeeds -join ', ') ]") + $yaml.Replace('jobs:/PostProcess:/', $postProcess.content) + } +} + +function ModifyUpdateALGoSystemFiles { + Param( + [Yaml] $yaml, + [hashtable] $repoSettings + ) + + if($repoSettings.Keys -notcontains 'UpdateALGoSystemFilesEnvironment') { + # If UpdateALGoSystemFilesEnvironment is not set, we don't need to do anything + return + } + + $updateALGoSystemFilesEnvironment = $repoSettings.UpdateALGoSystemFilesEnvironment + Write-Host "Setting 'Update AL-Go System Files' environment to $updateALGoSystemFilesEnvironment" + + # Add or replace the environment: section in the UpdateALGoSystemFiles job + $updateALGoSystemFilesJob = $yaml.Get('jobs:/UpdateALGoSystemFiles:/') + + if(-not $updateALGoSystemFilesJob) { + # Defensively check that the UpdateALGoSystemFiles job exists + throw "No UpdateALGoSystemFiles job found in the workflow" + } + + $environmentSection = $updateALGoSystemFilesJob.Get('environment:') + if($environmentSection) { + # If the environment: section already exists, replace it with the new environment + $updateALGoSystemFilesJob.Replace($environmentSection, "environment: $updateALGoSystemFilesEnvironment") + } + else { + # If the environment: section does not exist, add it + $updateALGoSystemFilesJob.Insert(1, "environment: $updateALGoSystemFilesEnvironment") + } + + $yaml.Replace('jobs:/UpdateALGoSystemFiles:/', $updateALGoSystemFilesJob.content) +} + +function ApplyWorkflowDefaultInputs { + Param( + [Yaml] $yaml, + [hashtable] $repoSettings, + [string] $workflowName + ) + + # Check if workflow_dispatch inputs exist + $workflowDispatch = $yaml.Get('on:/workflow_dispatch:/') + if (-not $workflowDispatch) { + # No workflow_dispatch section, nothing to do + return + } + + $inputs = $workflowDispatch.Get('inputs:/') + if (-not $inputs) { + # No inputs section, nothing to do + return + } + + if ($repoSettings.workflowDefaultInputs.Count -eq 0) { + # No defaults for this workflow + return + } + + # Get workflow_call inputs + $workflowCallInputs = $yaml.Get('on:/workflow_call:/inputs:/') + + # Apply defaults to matching inputs + $workflowDispatchInputsModified = $false + $workflowCallInputsModified = $false + + foreach ($defaultInput in $repoSettings.workflowDefaultInputs) { + # Apply to workflow_dispatch inputs and only update workflow_call if dispatch was modified + if (ApplyWorkflowDefaultInput -workflowName $workflowName -inputs $inputs -defaultInput $defaultInput) { + $workflowDispatchInputsModified = $true + + # Only apply to workflow_call inputs if the workflow_dispatch input was modified + if ($workflowCallInputs) { + if (ApplyWorkflowDefaultInput -workflowName $workflowName -inputs $workflowCallInputs -defaultInput $defaultInput) { + $workflowCallInputsModified = $true + } + } + } + } + + # Update the workflow_dispatch section if modified + if ($workflowDispatchInputsModified) { + $workflowDispatch.Replace('inputs:/', $inputs.content) + $yaml.Replace('on:/workflow_dispatch:/', $workflowDispatch.content) + } + + # Update workflow_call inputs if modified + if ($workflowCallInputsModified) { + $yaml.Replace('on:/workflow_call:/inputs:/', $workflowCallInputs.content) + } +} + +function ApplyWorkflowDefaultInput { + Param( + [string] $workflowName, + [Yaml] $inputs, + [hashtable] $defaultInput + ) + + $inputName = $defaultInput.name + $defaultValue = $defaultInput.value + + # Check if this input exists in the inputs collection + $inputSection = $inputs.Get("$($inputName):/") + if (-not $inputSection) { + # Input is not present in the workflow + return $false + } + + # Get the input type from the YAML if specified + $inputType = $null + $typeStart = 0 + $typeCount = 0 + if ($inputSection.Find('type:', [ref] $typeStart, [ref] $typeCount)) { + $typeLine = $inputSection.content[$typeStart].Trim() + if ($typeLine -match 'type:\s*(.+)') { + $inputType = $matches[1].Trim() + } + } + + # Validate that the value type matches the input type + $validationError = $null + if ($inputType) { + switch ($inputType) { + 'boolean' { + if ($defaultValue -isnot [bool]) { + $validationError = "Workflow '$workflowName', input '$inputName': Expected boolean value, but got $($defaultValue.GetType().Name). Please use `$true or `$false." + } + } + 'number' { + if ($defaultValue -isnot [int] -and $defaultValue -isnot [long] -and $defaultValue -isnot [double]) { + $validationError = "Workflow '$workflowName', input '$inputName': Expected number value, but got $($defaultValue.GetType().Name)." + } + } + 'string' { + if ($defaultValue -isnot [string]) { + $validationError = "Workflow '$workflowName', input '$inputName': Expected string value, but got $($defaultValue.GetType().Name)." + } + } + 'choice' { + # Choice inputs accept strings and must match one of the available options (case-sensitive) + if ($defaultValue -isnot [string]) { + $validationError = "Workflow '$workflowName', input '$inputName': Expected string value for choice input, but got $($defaultValue.GetType().Name)." + } + else { + # Validate that the value is one of the available options + $optionsStart = 0 + $optionsCount = 0 + if ($inputSection.Find('options:', [ref] $optionsStart, [ref] $optionsCount)) { + $availableOptions = @() + # Parse the options from the YAML (they are indented list items starting with "- ") + for ($i = $optionsStart + 1; $i -lt ($optionsStart + $optionsCount); $i++) { + $optionLine = $inputSection.content[$i].Trim() + if ($optionLine -match '^-\s*(.+)$') { + $availableOptions += $matches[1].Trim() + } + } + + if ($availableOptions.Count -gt 0 -and $availableOptions -cnotcontains $defaultValue) { + $validationError = "Workflow '$workflowName', input '$inputName': Value '$defaultValue' is not a valid choice (case-sensitive match required). Available options: $($availableOptions -join ', ')." + } + } + } + } + } + } + else { + # If no type is specified in the workflow, it defaults to string + if ($defaultValue -isnot [string]) { + OutputWarning "Workflow '$workflowName', input '$inputName': No type specified in workflow (defaults to string), but configured value is $($defaultValue.GetType().Name). This may cause issues." + } + } + + if ($validationError) { + throw $validationError + } + + # Convert the default value to the appropriate YAML format + $yamlValue = $defaultValue + if ($defaultValue -is [bool]) { + $yamlValue = $defaultValue.ToString().ToLower() + } + elseif ($defaultValue -is [string]) { + # Quote strings and escape single quotes per YAML spec + $escapedValue = $defaultValue.Replace("'", "''") + $yamlValue = "'$escapedValue'" + } + + # Find and replace the default: line in the input section + $start = 0 + $count = 0 + + if ($inputSection.Find('default:', [ref] $start, [ref] $count)) { + # Replace existing default value + $inputSection.Replace('default:', "default: $yamlValue") + } + else { + # Add default value - find the best place to insert it + # Insert after type, required, or description (whichever comes last) + $insertAfter = -1 + $typeLine = 0 + $typeCount = 0 + $requiredLine = 0 + $requiredCount = 0 + $descLine = 0 + $descCount = 0 + + if ($inputSection.Find('type:', [ref] $typeLine, [ref] $typeCount)) { + $insertAfter = $typeLine + $typeCount + } + if ($inputSection.Find('required:', [ref] $requiredLine, [ref] $requiredCount)) { + if (($requiredLine + $requiredCount) -gt $insertAfter) { + $insertAfter = $requiredLine + $requiredCount + } + } + if ($inputSection.Find('description:', [ref] $descLine, [ref] $descCount)) { + if (($descLine + $descCount) -gt $insertAfter) { + $insertAfter = $descLine + $descCount + } + } + + if ($insertAfter -eq -1) { + # No other properties, insert at position 1 (after the input name) + $insertAfter = 1 + } + + $inputSection.Insert($insertAfter, "default: $yamlValue") + } + + # Update the inputs collection with the modified input section + $inputs.Replace("$($inputName):/", $inputSection.content) + + return $true +} + +function GetWorkflowContentWithChangesFromSettings { + Param( + [string] $srcFile, + [hashtable] $repoSettings, + [int] $depth + ) + + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($srcFile) + $yaml = [Yaml]::Load($srcFile) + $yamlName = $yaml.get('name:') + if ($yamlName) { + $workflowName = $yamlName.content.SubString('name:'.Length).Trim().Trim('''"').Trim() + } + else { + $workflowName = $baseName + } + + $workflowScheduleKey = "WorkflowSchedule" + $workflowConcurrencyKey = "WorkflowConcurrency" + foreach($key in @($workflowScheduleKey,$workflowConcurrencyKey)) { + if ($repoSettings.Keys -contains $key -and ($repoSettings."$key")) { + throw "The $key setting is not allowed in the global repository settings. Please use the workflow specific settings file or conditional settings." + } + } + + # Re-read settings and this time include workflow specific settings + $repoSettings = ReadSettings -buildMode '' -project '' -workflowName $workflowName -userName '' -branchName '' | ConvertTo-HashTable -recurse + + # Old Schedule key is deprecated, but still supported + $oldWorkflowScheduleKey = "$($baseName)Schedule" + if ($repoSettings.Keys -contains $oldWorkflowScheduleKey) { + # DEPRECATION: REPLACE WITH ERROR AFTER October 1st 2025 ---> + if ($repoSettings.Keys -contains $workflowScheduleKey) { + OutputWarning "Both $oldWorkflowScheduleKey and $workflowScheduleKey are defined in the settings file. $oldWorkflowScheduleKey will be ignored. This warning will become an error in the future" + } + else { + Trace-DeprecationWarning -Message "$oldWorkflowScheduleKey is deprecated" -DeprecationTag "_workflow_Schedule" -WillBecomeError + # Convert the old Schedule setting to the new WorkflowSchedule setting + $repoSettings."$workflowScheduleKey" = @{ "cron" = $repoSettings."$oldWorkflowScheduleKey" } + } + # <--- REPLACE WITH ERROR AFTER October 1st 2025 + } + + # Any workflow (except for the PullRequestHandler and reusable workflows (_*)) can have concurrency and schedule defined + if ($baseName -ne "PullRequestHandler" -and $baseName -notlike '_*') { + # Add Schedule and Concurrency settings to the workflow + if ($repoSettings.Keys -contains $workflowScheduleKey) { + if ($repoSettings."$workflowScheduleKey" -isnot [hashtable] -or $repoSettings."$workflowScheduleKey".Keys -notcontains 'cron' -or $repoSettings."$workflowScheduleKey".cron -isnot [string]) { + throw "The $workflowScheduleKey setting must be a structure containing a cron property" + } + # Replace or add the schedule part under the on: key + $yaml.ReplaceOrAdd('on:/', 'schedule:', @("- cron: '$($repoSettings."$workflowScheduleKey".cron)'")) + } + if ($repoSettings.Keys -contains $workflowConcurrencyKey) { + # Replace or add the concurrency part + $yaml.ReplaceOrAdd('', 'concurrency:', $repoSettings."$workflowConcurrencyKey") + } + } + + if ($baseName -eq "CICD") { + ModifyCICDWorkflow -yaml $yaml -repoSettings $repoSettings + } + + if ($baseName -eq "PullRequestHandler") { + ModifyPullRequestHandlerWorkflow -yaml $yaml -repoSettings $repoSettings + } + + $criticalWorkflows = @('UpdateGitHubGoSystemFiles', 'Troubleshooting') + $allowedRunners = @('windows-latest', 'ubuntu-latest') + $modifyRunsOnAndShell = $true + + # Critical workflows may only run on allowed runners (must always be able to run) + if($criticalWorkflows -contains $baseName) { + if($allowedRunners -notcontains $repoSettings."runs-on") { + $modifyRunsOnAndShell = $false + } + } + + if ($modifyRunsOnAndShell) { + ModifyRunsOnAndShell -yaml $yaml -repoSettings $repoSettings + } + + # PullRequestHandler, CICD, Current, NextMinor and NextMajor workflows all include a build step. + # If the dependency depth is higher than 1, we need to add multiple dependent build jobs to the workflow + if ($baseName -eq 'PullRequestHandler' -or $baseName -eq 'CICD' -or $baseName -eq 'Current' -or $baseName -eq 'NextMinor' -or $baseName -eq 'NextMajor') { + $includeBuildPP = $repoSettings.type -eq 'PTE' -and $repoSettings.powerPlatformSolutionFolder -ne '' + ModifyBuildWorkflows -yaml $yaml -depth $depth -includeBuildPP $includeBuildPP + } + + if($baseName -eq 'UpdateGitHubGoSystemFiles') { + ModifyUpdateALGoSystemFiles -yaml $yaml -repoSettings $repoSettings + } + + # Apply workflow input defaults from settings + if ($repoSettings.Keys -contains 'workflowDefaultInputs') { + ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName $workflowName + } + + # combine all the yaml file lines into a single string with LF line endings + $yaml.content -join "`n" +} + +# Using direct AL-Go repo, we need to change the owner to the templateOwner, the repo names to AL-Go and AL-Go/Actions and the branch to templateBranch + +function ReplaceOwnerRepoAndBranch { + Param( + [ref] $srcContent, + [string] $templateOwner, + [string] $templateBranch + ) + + $lines = $srcContent.Value.Split("`n") + + # The Original Owner and Repo in the AL-Go repository are microsoft/AL-Go-Actions, microsoft/AL-Go-PTE and microsoft/AL-Go-AppSource + $originalOwnerAndRepo = @{ + "actionsRepo" = "microsoft/AL-Go-Actions" + "perTenantExtensionRepo" = "microsoft/AL-Go-PTE" + "appSourceAppRepo" = "microsoft/AL-Go-AppSource" + } + # Original branch is always main + $originalBranch = "main" + + # Modify the file to use repository names based on whether or not we are using the direct AL-Go repo + $templateRepos = @{ + "actionsRepo" = "AL-Go/Actions" + "perTenantExtensionRepo" = "AL-Go" + "appSourceAppRepo" = "AL-Go" + } + + # Replace URL's to actions repository first + $regex = "^(.*)https:\/\/raw\.githubusercontent\.com\/microsoft\/AL-Go-Actions\/$originalBranch(.*)$" + $replace = "`${1}https://raw.githubusercontent.com/$($templateOwner)/AL-Go/$($templateBranch)/Actions`${2}" + $lines = $lines | ForEach-Object { $_ -replace $regex, $replace } + + # Replace the owner and repo names in the workflow + "actionsRepo","perTenantExtensionRepo","appSourceAppRepo" | ForEach-Object { + $regex = "^(.*)$($originalOwnerAndRepo."$_")(.*)$originalBranch(.*)$" + $replace = "`${1}$($templateOwner)/$($templateRepos."$_")`${2}$($templateBranch)`${3}" + $lines = $lines | ForEach-Object { $_ -replace $regex, $replace } + } + $srcContent.Value = $lines -join "`n" +} + +function IsDirectALGo { + param ( + [string] $templateUrl + ) + $directALGo = $templateUrl -like 'https://github.com/*/AL-Go@*' + if ($directALGo) { + if ($templateUrl -like 'https://github.com/microsoft/AL-Go@*' -and -not ($templateUrl -like 'https://github.com/microsoft/AL-Go@*/*')) { + throw "You cannot use microsoft/AL-Go as a template repository. Please use microsoft/AL-Go-PTE, microsoft/AL-Go-AppSource or a fork of AL-Go instead." + } + } + return $directALGo +} + +function GetSrcFolder { + Param( + [string] $repoType, + [string] $templateUrl, + [string] $templateFolder, + [string] $srcPath = '' + ) + + if (!$templateUrl) { + return '' + } + if (IsDirectALGo -templateUrl $templateUrl) { + switch ($repoType) { + "PTE" { + $typePath = "Per Tenant Extension" + } + "AppSource App" { + $typePath = "AppSource App" + } + default { + throw "Unknown repository type" + } + } + $path = Join-Path $templateFolder "*/Templates/$typePath/.github/workflows" + } + else { + $path = Join-Path $templateFolder "*/.github/workflows" + } + # Due to this PowerShell bug: https://github.com/PowerShell/PowerShell/issues/6473#issuecomment-375930843 + # We need to resolve the path of a non-hidden folder (.github/workflows) + # and then get the parent folder of the parent folder of that path + $path = Resolve-Path -Path $path -ErrorAction SilentlyContinue + if (!$path) { + throw "No workflows found in the template repository" + } + $path = Join-Path -Path (Split-Path -Path (Split-Path -Path $path -Parent) -Parent) -ChildPath $srcPath + return $path +} + +function GetModifiedSettingsContent { + Param( + [string] $srcSettingsFile, + [string] $dstSettingsFile + ) + + $srcSettings = Get-ContentLF -Path $srcSettingsFile | ConvertFrom-Json + + $dstSettings = $null + if(Test-Path -Path $dstSettingsFile -PathType Leaf) { + $dstSettings = Get-ContentLF -Path $dstSettingsFile | ConvertFrom-Json + } + + if(!$dstSettings) { + # If the destination settings file does not exist or it's empty, create an new settings object with default values from the source settings (which includes the $schema property already) + $dstSettings = $srcSettings + } + else { + # Change the $schema property to be the same as the source settings file (add it if it doesn't exist) + $schemaKey = '$schema' + if ($srcSettings.PSObject.Properties.Name -eq $schemaKey) { + $schemaValue = $srcSettings."$schemaKey" + + $dstSettings | Add-Member -MemberType NoteProperty -Name "$schemaKey" -Value $schemaValue -Force + + # Make sure the $schema property is the first property in the object + # PS5-safe: explicitly list properties instead of using wildcard to avoid literal '*' being added + $otherProperties = @($dstSettings.PSObject.Properties.Name | Where-Object { $_ -ne $schemaKey }) + $selectProperties = @($schemaKey) + $otherProperties + $dstSettings = $dstSettings | Select-Object -Property $selectProperties + } + } + + return $dstSettings | ConvertTo-JsonLF +} + +function UpdateSettingsFile { + Param( + [string] $settingsFile, + [hashtable] $updateSettings + ) + + $modified = $false + # Update Repo Settings file with the template URL + if (Test-Path $settingsFile) { + $settings = Get-Content $settingsFile -Encoding UTF8 | ConvertFrom-Json + } + else { + $settings = [PSCustomObject]@{} + $modified = $true + } + foreach($key in $updateSettings.Keys) { + if ($settings.PSObject.Properties.Name -eq $key) { + if ($settings."$key" -ne $updateSettings."$key") { + $settings."$key" = $updateSettings."$key" + $modified = $true + } + } + else { + # Add the property if it doesn't exist + $settings | Add-Member -MemberType NoteProperty -Name "$key" -Value $updateSettings."$key" + $modified = $true + } + } + if ($modified) { + # Save the file with LF line endings and UTF8 encoding + $settings | Set-JsonContentLF -path $settingsFile + } + return $modified +} + +<# +.SYNOPSIS +Resolves file paths based on the provided source folder, destination folder, and file specifications. + +.DESCRIPTION +This function takes a source folder, an optional original source folder, a destination folder, and an array of file specifications. It resolves the full paths for each specified file, considering their origin (template or custom template), type, and whether they are per-project files. +The function returns an array of hashtables containing the resolved source and destination file paths. +The function is used to determine which files need to be copied from the template repository to the target repository during the AL-Go update process. +sourceFolder: The base folder of the template used to resolve the source file paths. +originalSourceFolder: The base folder of the original template used to check for original files (can be $null). This is in the case of custom templates, where if the file exists in the original template, it should be used instead of the custom template file. +destinationFolder: The base folder used to construct the destination file paths. This is typically the root folder of the target repository. + +.PARAMETER sourceFolder +The base folder where the source files are located. + +.PARAMETER originalSourceFolder +The original source folder to check for original files (can be $null). All files of origin 'custom template' are skipped if this parameter is $null. + +.PARAMETER destinationFolder +The base folder used to construct the destination file paths. + +.PARAMETER files +An array of hashtables specifying the files to resolve. Each hashtable can contain the following keys: +- sourceFolder: The subfolder within the source folder to search for files (default is current folder). +- filter: The file filter to apply when searching for files (default is all files). +- origin: The origin of the files, either 'template' or 'custom template' (default is 'template'). +- type: The type of the files (default is empty). +- destinationFolder: The subfolder within the destination folder where the files should be placed (default is the same as sourceFolder). +- perProject: A boolean indicating whether the files are per project (default is false). + +.PARAMETER projects +An array of project names used when resolving per-project file paths. + +.OUTPUTS +An array of hashtables, each containing: +- sourceFullPath: The full path to the source file. +- originalSourceFullPath: The full path to the original source file (if found, otherwise $null). +- type: The type of the file. +- destinationFullPath: The full path to the destination file. +#> +function ResolveFilePaths { + Param( + [Parameter(Mandatory=$true)] + [string] $sourceFolder, + [string] $originalSourceFolder = $null, + [Parameter(Mandatory=$true)] + [string] $destinationFolder, + [array] $files = @(), + [string[]] $projects = @() + ) + + if(-not $files) { + return @() + } + + $sourceFolder = Join-Path $sourceFolder '' # Ensure source folder has a trailing slash for correct path resolution + + $fullFilePaths = @() + foreach($file in $files) { + if($file.Keys -notcontains 'sourceFolder') { + $file.sourceFolder = '' # Default to current folder + } + + if($file.Keys -notcontains 'filter') { + $file.filter = '' # Default to all files + } + + if($file.Keys -notcontains 'origin') { + $file.origin = 'template' # Default to template + } + + if($file.Keys -notcontains 'type') { + $file.type = '' # Default to empty + } + + if($file.Keys -notcontains 'destinationFolder') { + # If destinationFolder is not specified, use the sourceFolder, so that the file structure is preserved + $file.destinationFolder = $file.sourceFolder + } + + if($file.Keys -notcontains 'perProject') { + $file.perProject = $false # Default to false + } + + # If originalSourceFolder is not specified, it means there is no custom template, so skip custom template files + if(!$originalSourceFolder -and $file.origin -eq 'custom template') { + OutputDebug "Skipping custom template file(s) with source folder '$($file.sourceFolder)' as there is no original source folder specified" + continue; + } + + # All files are relative to the template folder + OutputDebug "Resolving files for source folder '$($file.sourceFolder)' and filter '$($file.filter)'" + $sourceFiles = @(Get-ChildItem -Path (Join-Path $sourceFolder $file.sourceFolder) -Filter $file.filter -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName) + + OutputDebug "Found $($sourceFiles.Count) files for filter '$($file.filter)' in folder '$($file.sourceFolder)' (relative to folder '$sourceFolder', origin '$($file.origin)')" + + if(-not $sourceFiles) { + OutputDebug "No files found for filter '$($file.filter)' in folder '$($file.sourceFolder)' (relative to folder '$sourceFolder')" + continue + } + + foreach($srcFile in $sourceFiles) { + $fullFilePath = @{ + 'sourceFullPath' = $srcFile + 'originalSourceFullPath' = $null + 'type' = $file.type + 'destinationFullPath' = $null + } + + # Check if the source file is under the source folder + if ($srcFile -notlike "$sourceFolder*") { + OutputDebug "Skipping source file '$($srcFile)' as it is not under the source folder '$($sourceFolder)'." + continue + } + + OutputDebug "Processing file '$($srcFile)'" + + # Try to find the same files in the original template folder if it is specified. Exclude custom template files + if ($originalSourceFolder -and ($file.origin -ne 'custom template')) { + Push-Location $sourceFolder + $relativePath = Resolve-Path -Path $srcFile -Relative # resolve the path relative to the current location (template folder) + Pop-Location + if (Test-Path (Join-Path $originalSourceFolder $relativePath) -PathType Leaf) { + # If the file exists in the original template folder, use that file instead + $fullFilePath.originalSourceFullPath = Join-Path $originalSourceFolder $relativePath -Resolve + } + } + + $destinationName = Split-Path -Path $srcFile -Leaf + if($file.Keys -contains 'destinationName' -and ($file.destinationName)) { + $destinationName = $file.destinationName + } + + if($file.Keys -contains 'perProject' -and $file.perProject -eq $true) { + # Multiple file entries, one for each project + # Destination full path is the destination base folder + project + destinationFolder + destinationName + + foreach($project in $projects) { + if($project -eq '.') { + $project = '' # If project is '.', it means the root folder, so we use an empty string + } + + $fullProjectFilePath = $fullFilePath.Clone() + + $fullProjectFilePath.destinationFullPath = Join-Path $destinationFolder $project + $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $file.destinationFolder + $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $destinationName + + if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullProjectFilePath.destinationFullPath) { + OutputDebug "Skipping duplicate per-project file for project '$project': destinationFullPath '$($fullProjectFilePath.destinationFullPath)' already exists" + continue + } + OutputDebug "Adding per-project file for project '$project': sourceFullPath '$($fullProjectFilePath.sourceFullPath)', originalSourceFullPath '$($fullProjectFilePath.originalSourceFullPath)', destinationFullPath '$($fullProjectFilePath.destinationFullPath)'" + $fullFilePaths += $fullProjectFilePath + } + } + else { + # Single file entry + # Destination full path is the destination base folder + destinationFolder + destinationName + + $fullFilePath.destinationFullPath = Join-Path $destinationFolder $file.destinationFolder + $fullFilePath.destinationFullPath = Join-Path $fullFilePath.destinationFullPath $destinationName + + if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullFilePath.destinationFullPath) { + OutputDebug "Skipping duplicate file: destinationFullPath '$($fullFilePath.destinationFullPath)' already exists" + continue + } + OutputDebug "Adding file: sourceFullPath '$($fullFilePath.sourceFullPath)', originalSourceFullPath '$($fullFilePath.originalSourceFullPath)', destinationFullPath '$($fullFilePath.destinationFullPath)'" + $fullFilePaths += $fullFilePath + } + } + } + + return @($fullFilePaths) +} + +function GetDefaultFilesToInclude { + Param( + [switch] $includeCustomTemplateFiles + ) + + $filesToInclude = @( + [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = '*.yaml'; 'type' = 'workflow' } + [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = '*.yml'; 'type' = 'workflow' } + [ordered]@{ 'sourceFolder' = '.github'; 'filter' = '*.copy.md' } + [ordered]@{ 'sourceFolder' = '.github'; 'filter' = '*.ps1' } + [ordered]@{ 'sourceFolder' = '.github'; 'filter' = "$RepoSettingsFileName"; 'type' = 'settings' } + [ordered]@{ 'sourceFolder' = '.github'; 'filter' = '*.settings.json'; 'type' = 'settings' } + [ordered]@{ 'sourceFolder' = '.github/.agents'; 'filter' = '*.agent.md' } + + [ordered]@{ 'sourceFolder' = '.AL-Go'; 'filter' = '*.ps1'; 'perProject' = $true }, + [ordered]@{ 'sourceFolder' = '.AL-Go'; 'filter' = "$ALGoSettingsFileName"; 'perProject' = $true; 'type' = 'settings' } + ) + + if($includeCustomTemplateFiles) { + # If there is an original template folder, we also need to include custom template files (RepoSettings and ProjectSettings) + $filesToInclude += @( + [ordered]@{ 'sourceFolder' = ".github"; 'filter' = "$RepoSettingsFileName"; 'destinationName' = "$CustomTemplateRepoSettingsFileName"; 'origin' = 'custom template' } + [ordered]@{ 'sourceFolder' = ".AL-Go"; 'filter' = "$ALGoSettingsFileName"; 'destinationFolder' = '.github'; 'destinationName' = "$CustomTemplateProjectSettingsFileName"; 'origin' = 'custom template' } + ) + } + + return $filesToInclude +} + +function GetDefaultFilesToExclude { + Param( + $settings + ) + $filesToExclude = @() + + $includeBuildPP = $settings.type -eq 'PTE' -and $settings.powerPlatformSolutionFolder -ne '' + if (!$includeBuildPP) { + # Remove PowerPlatform workflows if no PowerPlatformSolution exists + $filesToExclude += @( + [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = '_BuildPowerPlatformSolution.yaml' } + [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = 'PushPowerPlatformChanges.yaml' } + [ordered]@{ 'sourceFolder' = '.github/workflows'; 'filter' = 'PullPowerPlatformChanges.yaml' } + ) + } + + return @($filesToExclude) +} + +<# +.SYNOPSIS + Reads settings using the current custom template repository settings without changing the workspace. +.DESCRIPTION + Temporarily refreshes the custom template repository settings snapshot, reads the merged settings, and restores + the snapshot to its original state. This allows the current template settings to affect the current run while + preserving the workspace state for the normal update comparison. +.PARAMETER baseFolder + The base folder of the repository whose settings are read. +.PARAMETER templateFolder + The folder where the custom template files are located. +#> +function ReadSettingsWithCurrentCustomTemplateRepoSettings { + Param( + [Parameter(Mandatory=$true)] + [string] $baseFolder, + [Parameter(Mandatory=$true)] + [string] $templateFolder + ) + + $templateFolderRepoSettingsPath = Join-Path $templateFolder $RepoSettingsFile + + $baseFolderTemplateSettingsPath = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $baseFolderTemplateSettingsBackupPath = $null + + if (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { + $baseFolderTemplateSettingsBackupPath = Join-Path (GetTemporaryPath) ([Guid]::NewGuid().ToString()) + Copy-Item -LiteralPath $baseFolderTemplateSettingsPath -Destination $baseFolderTemplateSettingsBackupPath -Force + } + + try { + if (Test-Path -LiteralPath $templateFolderRepoSettingsPath -PathType Leaf) { + Copy-Item -LiteralPath $templateFolderRepoSettingsPath -Destination $baseFolderTemplateSettingsPath -Force + } + return ReadSettings -baseFolder $baseFolder -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse + } + finally { + if ($baseFolderTemplateSettingsBackupPath) { + Copy-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Destination $baseFolderTemplateSettingsPath -Force + Remove-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Force + } + elseif (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { + Remove-Item -LiteralPath $baseFolderTemplateSettingsPath -Force + } + } +} + +<# +.SYNOPSIS + Get the list of files from the template repository to include and exclude based on the provided settings. +.DESCRIPTION + Builds two lists by merging defaults, repository settings, and the original AL-Go template (if given): + + 1. filesToInclude: Files to copy from the template or original template to the destination. + Built from default files to include and customALGoFiles.filesToInclude in settings, resolved against the template folder and original template folder (if any). + 2. filesToExclude: Files to skip from copying; if they already exist in the destination they should be deleted. + Built from default files to exclude and customALGoFiles.filesToExclude in settings, resolved against the template folder and original template folder (if any). + + Note: when a custom template is in use, the caller is expected to call + ReadSettingsWithCurrentCustomTemplateRepoSettings before this function, so that the template's + customALGoFiles/unusedALGoSystemFiles are already merged into settings. + + The deprecated unusedALGoSystemFiles setting is also applied: matching files are moved from filesToInclude to + filesToExclude with a deprecation warning. +.PARAMETER settings + The settings object containing the customALGoFiles configuration. +.PARAMETER baseFolder + The base folder of the repository. This is the target folder where the files will be updated. +.PARAMETER templateFolder + The folder where the template files are located. +.PARAMETER originalTemplateFolder + The folder where the original AL-Go template files are located (if any). + When provided, it signals that a custom template is in use. Both filesToInclude and filesToExclude specs are + resolved against this folder in addition to templateFolder; entries not already covered by originalSourceFullPath + tracking are appended to propagate upstream template additions and deletions to consumer repositories. +.PARAMETER projects + The list of projects in the repository. + The projects are used to resolve per-project files. +.OUTPUTS + An array containing two elements: the list of files to include and the list of files to exclude. + Files are represented as hashtables with the following keys: + - sourceFullPath: The full path to the source file. + - originalSourceFullPath: The full path to the original source file in the original template repository (if any). + - type: The type of the file (e.g., workflow, settings). + - destinationFullPath: The full path to the destination file in the target repository. +#> +function GetFilesToUpdate { + Param( + [Parameter(Mandatory=$true)] + $settings, + [Parameter(Mandatory=$true)] + $baseFolder, + [Parameter(Mandatory=$true)] + $templateFolder, + $originalTemplateFolder = $null, + $projects = @() + ) + + $hasOriginalTemplate = $null -ne $originalTemplateFolder + Write-Host "Getting files to update from template folder '$templateFolder', original template folder '$originalTemplateFolder' and base folder '$baseFolder'" + + # Send telemetery about customALGoFiles usage + if ($settings.customALGoFiles.filesToInclude.Count -gt 0) { + Trace-Information -Message "Usage: Custom AL-Go Files (Include)" + } + if ($settings.customALGoFiles.filesToExclude.Count -gt 0) { + Trace-Information -Message "Usage: Custom AL-Go Files (Exclude)" + } + # Determine files to include + $filesToIncludeUnresolved = GetDefaultFilesToInclude -includeCustomTemplateFiles:$hasOriginalTemplate + $filesToIncludeUnresolved += $settings.customALGoFiles.filesToInclude + $filesToInclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) + if ($hasOriginalTemplate) { + $filesToInclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) + } + # Deduplicate files to include based on destinationFullPath, keeping the first one (default > settings; template folder > original template folder) + $filesToInclude = @($filesToInclude | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[0] }) + + # Determine files to exclude + $filesToExcludeUnresolved = GetDefaultFilesToExclude -settings $settings + $filesToExcludeUnresolved += $settings.customALGoFiles.filesToExclude + $filesToExclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) + if ($hasOriginalTemplate) { + $filesToExclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) + } + # filesToExclude is not deduplicated by destinationFullPath here. + # Its destinationFullPath is never part of the actual output; only sourceFullPath is used below to match against filesToInclude. + + # Map files from filesToExclude to files that are in filesToInclude (based on source) + # Settings for filesToExclude only define the sources (sourceFolder and filter) but not the destinations (destinationFolder, destinationName and perProject) + $filesToExclude = @($filesToInclude | Where-Object { + $fileToInclude = $_ + return $filesToExclude | Where-Object { $_.sourceFullPath -eq $fileToInclude.sourceFullPath } + }) + + # Exclude files from filesToInclude that are in filesToExclude (based on source) + $filesToInclude = @($filesToInclude | Where-Object { + $file = $_ + $include = -not ($filesToExclude | Where-Object { $_.sourceFullPath -eq $file.sourceFullPath }) + if (-not $include) { OutputDebug "Excluding source file '$($file.sourceFullPath)' from include list as it is in the exclude list" } + return $include + }) + + # Apply unusedALGoSystemFiles logic + $unusedALGoSystemFiles = $settings.unusedALGoSystemFiles + + # Exclude unusedALGoSystemFiles from $filesToInclude and add them to $filesToExclude + $unusedFilesToExclude = $filesToInclude | Where-Object { $unusedALGoSystemFiles -contains (Split-Path -Path $_.sourceFullPath -Leaf) } + if ($unusedFilesToExclude) { + Trace-DeprecationWarning "The 'unusedALGoSystemFiles' setting is deprecated and will be removed in future versions." -DeprecationTag "unusedALGoSystemFiles" + + OutputDebug "The following files are marked as unused and will be removed if they exist:" + $unusedFilesToExclude | ForEach-Object { OutputDebug "- $($_.destinationFullPath)" } + + $filesToInclude = @($filesToInclude | Where-Object { $unusedALGoSystemFiles -notcontains (Split-Path -Path $_.sourceFullPath -Leaf) }) + $filesToExclude += @($unusedFilesToExclude) + } + + # List all files to be included and excluded with their source and destination paths, type and original source path (if any) + $fileFormatter = { param($file) " -Source: $($file.sourceFullPath), Destination: $($file.destinationFullPath), Type: $($file.type), Original Source: $($file.originalSourceFullPath)"} + OutputArray -Message "Files to include: $($filesToInclude.Count)" -Array $filesToInclude -Formatter $fileFormatter + OutputArray -Message "Files to exclude: $($filesToExclude.Count)" -Array $filesToExclude -Formatter $fileFormatter + + return @($filesToInclude), @($filesToExclude) +} diff --git a/e2eTests/scenarios/CustomTemplate/runtest.ps1 b/e2eTests/scenarios/CustomTemplate/runtest.ps1 index 2b88e4cd0d..423cfe2226 100644 --- a/e2eTests/scenarios/CustomTemplate/runtest.ps1 +++ b/e2eTests/scenarios/CustomTemplate/runtest.ps1 @@ -253,8 +253,6 @@ Get-ContentLF -Path (Join-Path (Get-Location) $defaultCustomFileName) | Should - # Check that optional custom file is present (Join-Path (Get-Location) $optionalCustomFileName) | Should -Exist Get-ContentLF -Path (Join-Path (Get-Location) $optionalCustomFileName) | Should -Be $optionalCustomFileContent.Replace("`r", "").TrimEnd("`n") -# Check that legacy custom file is NOT present -(Join-Path (Get-Location) $legacyCustomFileName) | Should -Not -Exist # Check that excluded workflow file is NOT present (in template's filesToExclude) (Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Not -Exist @@ -355,8 +353,6 @@ Remove-Item -Path (Join-Path (Get-Location) $missingWorkflowFileRelativePath) -F (Join-Path (Get-Location) $defaultCustomFileName) | Should -Not -Exist # Check that optional custom file is NOT present in final repository (Join-Path (Get-Location) $optionalCustomFileName) | Should -Not -Exist -# Check that legacy custom file is present in final repository -(Join-Path (Get-Location) $legacyCustomFileName) | Should -Exist # Check that excluded workflow file is present in final repository (Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Exist From 73e159c7682889e1bcb470b1c93213d9ad4cba50 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Wed, 29 Jul 2026 16:46:26 +0200 Subject: [PATCH 15/34] Cleanup --- Actions/CheckForUpdates/CheckForUpdates.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index 9b8a3f7127..3025bf2f6b 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -210,7 +210,7 @@ foreach($fileToInclude in $filesToInclude) { Push-Location -Path $baseFolder # Remove files that are in $filesToExclude and exist in the repository -$removeFiles = @($filesToExclude) | Where-Object { $_ -and (Test-Path -Path $_.destinationFullPath -PathType Leaf) } | ForEach-Object { +$removeFiles = $filesToExclude | Where-Object { $_ -and (Test-Path -Path $_.destinationFullPath -PathType Leaf) } | ForEach-Object { $relativePath = Resolve-Path -Path $_.destinationFullPath -Relative Write-Host "File marked for removal: $relativePath" $relativePath From cf711430c487e4b9c896cae53b451273fc156a23 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Wed, 29 Jul 2026 20:19:44 +0200 Subject: [PATCH 16/34] Fixed boundary escape for destinationFolder and destinationName --- .../CheckForUpdates.HelperFunctions.ps1 | 24 ++++- Tests/CheckForUpdates.Action.Test.ps1 | 97 ++++++++++++++----- 2 files changed, 96 insertions(+), 25 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index fcafee2666..990d804db4 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -827,8 +827,12 @@ function ResolveFilePaths { return @() } + $sourceFolder = [System.IO.Path]::GetFullPath($sourceFolder) # Canonicalize the source folder to an absolute path $sourceFolder = Join-Path $sourceFolder '' # Ensure source folder has a trailing slash for correct path resolution + $destinationFolder = [System.IO.Path]::GetFullPath($destinationFolder) # Canonicalize the destination folder to an absolute path + $destinationFolder = Join-Path $destinationFolder '' # Ensure destination folder has a trailing slash for correct path resolution + $fullFilePaths = @() foreach($file in $files) { if($file.Keys -notcontains 'sourceFolder') { @@ -914,11 +918,20 @@ function ResolveFilePaths { $project = '' # If project is '.', it means the root folder, so we use an empty string } + $projectDestinationFolder = Join-Path $destinationFolder $project + $projectDestinationFolder = Join-Path $projectDestinationFolder '' # Ensure project destination folder has a trailing slash for correct path resolution + $fullProjectFilePath = $fullFilePath.Clone() - $fullProjectFilePath.destinationFullPath = Join-Path $destinationFolder $project - $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $file.destinationFolder + $fullProjectFilePath.destinationFullPath = Join-Path $projectDestinationFolder $file.destinationFolder $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $destinationName + $fullProjectFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullProjectFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path + + # Check if the destination file is under the project destination folder + if ($fullProjectFilePath.destinationFullPath -notlike "$projectDestinationFolder*") { + OutputWarning "Skipping file '$srcFile' for project '$project': destination file '$($fullProjectFilePath.destinationFullPath)' is outside the project destination folder '$projectDestinationFolder'." + continue + } if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullProjectFilePath.destinationFullPath) { OutputDebug "Skipping duplicate per-project file for project '$project': destinationFullPath '$($fullProjectFilePath.destinationFullPath)' already exists" @@ -934,6 +947,13 @@ function ResolveFilePaths { $fullFilePath.destinationFullPath = Join-Path $destinationFolder $file.destinationFolder $fullFilePath.destinationFullPath = Join-Path $fullFilePath.destinationFullPath $destinationName + $fullFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path + + # Check if the destination file is under the destination folder + if ($fullFilePath.destinationFullPath -notlike "$destinationFolder*") { + OutputWarning "Skipping file '$srcFile': resolved destination '$($fullFilePath.destinationFullPath)' is outside the destination folder '$destinationFolder'." + continue + } if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullFilePath.destinationFullPath) { OutputDebug "Skipping duplicate file: destinationFullPath '$($fullFilePath.destinationFullPath)' already exists" diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index b3a5fa8fe7..ecde79b5be 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -1,4 +1,4 @@ -Get-Module TestActionsHelper | Remove-Module -Force +Get-Module TestActionsHelper | Remove-Module -Force Import-Module (Join-Path $PSScriptRoot 'TestActionsHelper.psm1') Import-Module (Join-Path $PSScriptRoot "../Actions/TelemetryHelper.psm1") Import-Module (Join-Path $PSScriptRoot '../Actions/.Modules/ReadSettings.psm1') @@ -1320,6 +1320,54 @@ Describe "ResolveFilePaths" { $fullFilePaths[1].type | Should -Be '' } + It 'ResolveFilePaths warns and skips destination names outside the destination folder' { + $destinationFolder = Join-Path $rootFolder "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "destinationName" = "../../outside.txt" } + @{ "sourceFolder" = "folder"; "filter" = "File2.log" } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File2.log") + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File2.log") + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the destination folder*" } + } + + It 'ResolveFilePaths warns and skips destination folders outside the destination folder' { + $destinationFolder = Join-Path $rootFolder "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "destinationFolder" = "../outside" } + @{ "sourceFolder" = "folder"; "filter" = "File2.log" } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File2.log") + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File2.log") + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the destination folder*" } + } + + It 'ResolveFilePaths warns and skips per-project destinations outside the project destination folder' { + $destinationFolder = Join-Path $rootFolder "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "destinationFolder" = "../outside"; "perProject" = $true } + @{ "sourceFolder" = "folder"; "filter" = "File2.log"; "perProject" = $true } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @("ProjectAlpha")) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File2.log") + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectAlpha/folder/File2.log") + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the project destination folder*" } + } + It 'ResolveFilePaths with type' { $destinationFolder = "destinationFolder" $destinationFolder = Join-Path $PSScriptRoot $destinationFolder @@ -2627,6 +2675,9 @@ Describe "GetFilesToUpdate (real template)" { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'realAppSourceAppTemplateFolder', Justification = 'False positive.')] $realAppSourceAppTemplateFolder = Join-Path $PSScriptRoot "../Templates/AppSource App" -Resolve + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'baseFolder', Justification = 'False positive.')] + $baseFolder = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot 'baseFolder')) + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'powerPlatformFiles', Justification = 'False positive.')] $powerPlatformFiles = @( ".github/workflows/_BuildPowerPlatformSolution.yaml", @@ -2646,7 +2697,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 25 @@ -2670,9 +2721,9 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder - $repoSettingsDestination = Join-Path 'baseFolder' (Join-Path '.github' $RepoSettingsFileName) + $repoSettingsDestination = Join-Path $baseFolder (Join-Path '.github' $RepoSettingsFileName) $conflict = @($filesToInclude | Where-Object { $_.destinationFullPath -eq $repoSettingsDestination }) $conflict.Count | Should -Be 1 @@ -2691,7 +2742,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder # The default exclude entries (PowerPlatform files) and the repository settings' own exclude entry are both applied $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $powerPlatformFiles[0]) @@ -2713,7 +2764,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder $ppFileSourcePath = Join-Path $realPTETemplateFolder $powerPlatformFiles[0] @($filesToExclude | Where-Object { $_.sourceFullPath -eq $ppFileSourcePath }).Count | Should -Be 1 @@ -2731,7 +2782,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 22 @@ -2749,7 +2800,7 @@ Describe "GetFilesToUpdate (real template)" { for ($i = 0; $i -lt $powerPlatformFiles.Count; $i++) { $filesToExclude[$i].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder $powerPlatformFiles[$i]) - $filesToExclude[$i].destinationFullPath | Should -Be (Join-Path 'baseFolder' $powerPlatformFiles[$i]) + $filesToExclude[$i].destinationFullPath | Should -Be (Join-Path $baseFolder $powerPlatformFiles[$i]) } } @@ -2764,7 +2815,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 24 @@ -2773,7 +2824,7 @@ Describe "GetFilesToUpdate (real template)" { $filesToExclude | Should -Not -BeNullOrEmpty $filesToExclude.Count | Should -Be 1 $filesToExclude[0].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder ".github/Test Next Major.settings.json") - $filesToExclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' '.github/Test Next Major.settings.json') + $filesToExclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder '.github/Test Next Major.settings.json') } It 'Return the correct files when unusedALGoSystemFiles is specified and no PP solution is present' { @@ -2787,7 +2838,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 21 @@ -2815,7 +2866,7 @@ Describe "GetFilesToUpdate (real template)" { $customTemplateFolder = $realPTETemplateFolder $originalTemplateFolder = $realAppSourceAppTemplateFolder - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template $filesToInclude | Should -Not -BeNullOrEmpty @@ -2826,11 +2877,11 @@ Describe "GetFilesToUpdate (real template)" { $repoSettingsFiles.Count | Should -Be 2 $repoSettingsFiles[0].originalSourceFullPath | Should -Be (Join-Path $originalTemplateFolder ".github/AL-Go-Settings.json") - $repoSettingsFiles[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' '.github/AL-Go-Settings.json') + $repoSettingsFiles[0].destinationFullPath | Should -Be (Join-Path $baseFolder '.github/AL-Go-Settings.json') $repoSettingsFiles[0].type | Should -Be 'settings' $repoSettingsFiles[1].originalSourceFullPath | Should -Be $null # Because origin is 'custom template', originalSourceFullPath should be $null - $repoSettingsFiles[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' '.github/AL-Go-TemplateRepoSettings.doNotEdit.json') + $repoSettingsFiles[1].destinationFullPath | Should -Be (Join-Path $baseFolder '.github/AL-Go-TemplateRepoSettings.doNotEdit.json') $repoSettingsFiles[1].type | Should -Be '' # Check project settings files @@ -2840,11 +2891,11 @@ Describe "GetFilesToUpdate (real template)" { $projectSettingsFilesFromCustomTemplate.Count | Should -Be 2 $projectSettingsFilesFromCustomTemplate[0].originalSourceFullPath | Should -Be (Join-Path $originalTemplateFolder ".AL-Go/settings.json") - $projectSettingsFilesFromCustomTemplate[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' '.AL-Go/settings.json') + $projectSettingsFilesFromCustomTemplate[0].destinationFullPath | Should -Be (Join-Path $baseFolder '.AL-Go/settings.json') $projectSettingsFilesFromCustomTemplate[0].type | Should -Be 'settings' $projectSettingsFilesFromCustomTemplate[1].originalSourceFullPath | Should -Be $null # Because origin is 'custom template', originalSourceFullPath should be $null - $projectSettingsFilesFromCustomTemplate[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' '.github/AL-Go-TemplateProjectSettings.doNotEdit.json') + $projectSettingsFilesFromCustomTemplate[1].destinationFullPath | Should -Be (Join-Path $baseFolder '.github/AL-Go-TemplateProjectSettings.doNotEdit.json') $projectSettingsFilesFromCustomTemplate[1].type | Should -Be '' # No files to exclude or remove @@ -2865,7 +2916,7 @@ Describe "GetFilesToUpdate (real template)" { # AppSource App is used as custom template because it has no PP workflows, simulating a custom PTE fork that stripped them out $customTemplateFolder = $realAppSourceAppTemplateFolder $originalTemplateFolder = $realPTETemplateFolder - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template $filesToInclude | Should -Not -BeNullOrEmpty $powerPlatformFiles | ForEach-Object { @@ -2891,7 +2942,7 @@ Describe "GetFilesToUpdate (real template)" { # AppSource App is used as custom template because it has no PP workflows, simulating a custom PTE fork that stripped them out $customTemplateFolder = $realAppSourceAppTemplateFolder $originalTemplateFolder = $realPTETemplateFolder - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template $filesToInclude | Should -Not -BeNullOrEmpty $powerPlatformFiles | ForEach-Object { @@ -2919,7 +2970,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realAppSourceAppTemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realAppSourceAppTemplateFolder # PowerPlatform files should be excluded for AppSource App too (same as PTE) $filesToInclude | Should -Not -BeNullOrEmpty @@ -2943,7 +2994,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder # No additional files should be excluded due to unusedALGoSystemFiles $ppExcludes = $filesToExclude | Where-Object { $_.sourceFullPath -like "*_BuildPowerPlatformSolution.yaml" -or $_.sourceFullPath -like "*PullPowerPlatformChanges.yaml" -or $_.sourceFullPath -like "*PushPowerPlatformChanges.yaml" } @@ -2961,7 +3012,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -projects @('Project1') + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder -projects @('Project1') # Check that settings files have type = 'settings' $repoSettingsFiles = @($filesToInclude | Where-Object { $_.sourceFullPath -like "*$RepoSettingsFileName" -and $_.destinationFullPath -like "*.github*$RepoSettingsFileName" }) @@ -2985,7 +3036,7 @@ Describe "GetFilesToUpdate (real template)" { } $projects = @('ProjectA', 'ProjectB', 'ProjectC') - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -projects $projects + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder -projects $projects # Each project should have its own settings file $projectASettings = $filesToInclude | Where-Object { $_.destinationFullPath -like "*ProjectA*.AL-Go*" } @@ -3008,7 +3059,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder # Test Next Major.settings.json should be excluded $testNextMajor = $filesToInclude | Where-Object { $_.sourceFullPath -like "*Test Next Major.settings.json" } From 61c640d15ed39fdb9eecd3b96849c3c0d7e025ca Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Fri, 31 Jul 2026 20:57:38 +0200 Subject: [PATCH 17/34] Fixed boundary escape for destinationName and typos --- .../CheckForUpdates.HelperFunctions.ps1 | 27 +++++----- Tests/CheckForUpdates.Action.Test.ps1 | 51 ++++++++----------- e2eTests/scenarios/CustomTemplate/runtest.ps1 | 2 +- 3 files changed, 37 insertions(+), 43 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 990d804db4..75d16f72b4 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -918,18 +918,17 @@ function ResolveFilePaths { $project = '' # If project is '.', it means the root folder, so we use an empty string } - $projectDestinationFolder = Join-Path $destinationFolder $project - $projectDestinationFolder = Join-Path $projectDestinationFolder '' # Ensure project destination folder has a trailing slash for correct path resolution + $fileDestinationFolder = Join-Path $destinationFolder $project + $fileDestinationFolder = Join-Path $fileDestinationFolder $file.destinationFolder + $fileDestinationFolder = Join-Path $fileDestinationFolder '' # Ensure file destination folder has a trailing slash for correct path resolution $fullProjectFilePath = $fullFilePath.Clone() - - $fullProjectFilePath.destinationFullPath = Join-Path $projectDestinationFolder $file.destinationFolder - $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $destinationName + $fullProjectFilePath.destinationFullPath = Join-Path $fileDestinationFolder $destinationName $fullProjectFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullProjectFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path - # Check if the destination file is under the project destination folder - if ($fullProjectFilePath.destinationFullPath -notlike "$projectDestinationFolder*") { - OutputWarning "Skipping file '$srcFile' for project '$project': destination file '$($fullProjectFilePath.destinationFullPath)' is outside the project destination folder '$projectDestinationFolder'." + # Check if the destination file is under the file destination folder + if ($fullProjectFilePath.destinationFullPath -notlike "$fileDestinationFolder*") { + OutputWarning "Skipping file '$srcFile' for project '$project': destination file '$($fullProjectFilePath.destinationFullPath)' is outside the destination folder '$fileDestinationFolder'." continue } @@ -945,13 +944,15 @@ function ResolveFilePaths { # Single file entry # Destination full path is the destination base folder + destinationFolder + destinationName - $fullFilePath.destinationFullPath = Join-Path $destinationFolder $file.destinationFolder - $fullFilePath.destinationFullPath = Join-Path $fullFilePath.destinationFullPath $destinationName + $fileDestinationFolder = Join-Path $destinationFolder $file.destinationFolder + $fileDestinationFolder = Join-Path $fileDestinationFolder '' # Ensure file destination folder has a trailing slash for correct path resolution + + $fullFilePath.destinationFullPath = Join-Path $fileDestinationFolder $destinationName $fullFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path - # Check if the destination file is under the destination folder - if ($fullFilePath.destinationFullPath -notlike "$destinationFolder*") { - OutputWarning "Skipping file '$srcFile': resolved destination '$($fullFilePath.destinationFullPath)' is outside the destination folder '$destinationFolder'." + # Check if the destination file is under the file destination folder + if ($fullFilePath.destinationFullPath -notlike "$fileDestinationFolder*") { + OutputWarning "Skipping file '$srcFile': destination file '$($fullFilePath.destinationFullPath)' is outside the destination folder '$fileDestinationFolder'." continue } diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index ecde79b5be..f72b626722 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -1320,52 +1320,45 @@ Describe "ResolveFilePaths" { $fullFilePaths[1].type | Should -Be '' } - It 'ResolveFilePaths warns and skips destination names outside the destination folder' { + It 'ResolveFilePaths warns and skips destinations outside the destination folder' { $destinationFolder = Join-Path $rootFolder "destinationFolder" + $destinationSubfolder = Join-Path $destinationFolder "subfolder" $files = @( - @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "destinationName" = "../../outside.txt" } - @{ "sourceFolder" = "folder"; "filter" = "File2.log" } + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "destinationName" = "../outside.txt" } + @{ "sourceFolder" = "folder"; "filter" = "File2.log"; "destinationFolder" = "../outside" } + @{ "sourceFolder" = "folder"; "filter" = "File3.txt"; "destinationFolder" = "subfolder"; "destinationName" = "../outside.txt" } + @{ "sourceFolder" = "folder"; "filter" = "File4.md" } ) Mock OutputWarning {} $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) $fullFilePaths.Count | Should -Be 1 - $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File2.log") - $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File2.log") - Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the destination folder*" } + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File4.md") + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File4.md") + Should -Invoke OutputWarning -Times 3 -ParameterFilter { $message -like "*outside the destination folder '$destinationFolder*" } + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the destination folder '$destinationSubfolder*" } } - It 'ResolveFilePaths warns and skips destination folders outside the destination folder' { + It 'ResolveFilePaths warns and skips per-project destinations outside the destination folder' { $destinationFolder = Join-Path $rootFolder "destinationFolder" + $destinationProjectFolder = Join-Path $destinationFolder "project" + $destinationProjectSubfolder = Join-Path $destinationProjectFolder "subfolder" $files = @( - @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "destinationFolder" = "../outside" } - @{ "sourceFolder" = "folder"; "filter" = "File2.log" } + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "destinationName" = "../outside.txt"; "perProject" = $true } + @{ "sourceFolder" = "folder"; "filter" = "File2.log"; "destinationFolder" = "../outside"; "perProject" = $true } + @{ "sourceFolder" = "folder"; "filter" = "File3.txt"; "destinationFolder" = "subfolder"; "destinationName" = "../outside.txt"; "perProject" = $true } + @{ "sourceFolder" = "folder"; "filter" = "File4.md"; "perProject" = $true } ) Mock OutputWarning {} - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @("project")) $fullFilePaths.Count | Should -Be 1 - $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File2.log") - $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File2.log") - Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the destination folder*" } - } - - It 'ResolveFilePaths warns and skips per-project destinations outside the project destination folder' { - $destinationFolder = Join-Path $rootFolder "destinationFolder" - $files = @( - @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "destinationFolder" = "../outside"; "perProject" = $true } - @{ "sourceFolder" = "folder"; "filter" = "File2.log"; "perProject" = $true } - ) - Mock OutputWarning {} - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @("ProjectAlpha")) - - $fullFilePaths.Count | Should -Be 1 - $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File2.log") - $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectAlpha/folder/File2.log") - Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the project destination folder*" } + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File4.md") + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "project/folder/File4.md") + Should -Invoke OutputWarning -Times 3 -ParameterFilter { $message -like "*outside the destination folder '$destinationProjectFolder*" } + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the destination folder '$destinationProjectSubfolder*" } } It 'ResolveFilePaths with type' { diff --git a/e2eTests/scenarios/CustomTemplate/runtest.ps1 b/e2eTests/scenarios/CustomTemplate/runtest.ps1 index 423cfe2226..93132e89e3 100644 --- a/e2eTests/scenarios/CustomTemplate/runtest.ps1 +++ b/e2eTests/scenarios/CustomTemplate/runtest.ps1 @@ -438,7 +438,7 @@ Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | # Check that default custom file is NOT present (in repo's filesToExclude and template's filesToInclude) (Join-Path (Get-Location) $defaultCustomFileName) | Should -Not -Exist -# Check that optional custom file is present (in repos's filesToInclude) +# Check that optional custom file is present (in repo's filesToInclude) (Join-Path (Get-Location) $optionalCustomFileName) | Should -Exist Get-ContentLF -Path (Join-Path (Get-Location) $optionalCustomFileName) | Should -Be $optionalCustomFileContent.Replace("`r", "").TrimEnd("`n") From ea2688ea21b5a7d1c209a12ba2cd8366ccdd82fe Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Fri, 31 Jul 2026 23:23:25 +0200 Subject: [PATCH 18/34] Fixed boundry escape for wildcards and for linux --- .../CheckForUpdates.HelperFunctions.ps1 | 11 +++-- Tests/CheckForUpdates.Action.Test.ps1 | 47 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 75d16f72b4..dc99a8f852 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -833,6 +833,11 @@ function ResolveFilePaths { $destinationFolder = [System.IO.Path]::GetFullPath($destinationFolder) # Canonicalize the destination folder to an absolute path $destinationFolder = Join-Path $destinationFolder '' # Ensure destination folder has a trailing slash for correct path resolution + $pathComparison = [System.StringComparison]::OrdinalIgnoreCase + if ($PSVersionTable.PSVersion.Major -ge 6 -and ($IsLinux -or $IsMacOS)) { + $pathComparison = [System.StringComparison]::Ordinal + } + $fullFilePaths = @() foreach($file in $files) { if($file.Keys -notcontains 'sourceFolder') { @@ -886,7 +891,7 @@ function ResolveFilePaths { } # Check if the source file is under the source folder - if ($srcFile -notlike "$sourceFolder*") { + if (-not $srcFile.StartsWith($sourceFolder, $pathComparison)) { OutputDebug "Skipping source file '$($srcFile)' as it is not under the source folder '$($sourceFolder)'." continue } @@ -927,7 +932,7 @@ function ResolveFilePaths { $fullProjectFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullProjectFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path # Check if the destination file is under the file destination folder - if ($fullProjectFilePath.destinationFullPath -notlike "$fileDestinationFolder*") { + if (-not $fullProjectFilePath.destinationFullPath.StartsWith($fileDestinationFolder, $pathComparison)) { OutputWarning "Skipping file '$srcFile' for project '$project': destination file '$($fullProjectFilePath.destinationFullPath)' is outside the destination folder '$fileDestinationFolder'." continue } @@ -951,7 +956,7 @@ function ResolveFilePaths { $fullFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path # Check if the destination file is under the file destination folder - if ($fullFilePath.destinationFullPath -notlike "$fileDestinationFolder*") { + if (-not $fullFilePath.destinationFullPath.StartsWith($fileDestinationFolder, $pathComparison)) { OutputWarning "Skipping file '$srcFile': destination file '$($fullFilePath.destinationFullPath)' is outside the destination folder '$fileDestinationFolder'." continue } diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index f72b626722..def629004a 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -1555,6 +1555,53 @@ Describe "ResolveFilePaths" { if (Test-Path $externalFolder) { Remove-Item -Path $externalFolder -Recurse -Force } } + It 'ResolveFilePaths skips files in a source folder that differs only by case' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or $IsWindows) { + $externalFolder = Join-Path $rootFolder 'sourcefolder' + $externalFile = Join-Path $externalFolder 'outside.txt' + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + Set-Content -Path $externalFile -Value 'outside' + + try { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $files = @( + @{ 'sourceFolder' = '../sourcefolder'; 'filter' = '*.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -BeNullOrEmpty + } + finally { + if (Test-Path $externalFolder) { Remove-Item -Path $externalFolder -Recurse -Force } + } + } + + It 'ResolveFilePaths skips destinations in a folder that differs only by case' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or $IsWindows) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = '../casefolder/outside.txt' } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 + } + + It 'ResolveFilePaths skips per-project destinations in a folder that differs only by case' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or $IsWindows) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = ''; 'destinationName' = '../caseproject/outside.txt'; 'perProject' = $true } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('CaseProject')) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 + } + It 'ResolveFilePaths returns empty when no files match filter' { $destinationFolder = "destinationFolder" $destinationFolder = Join-Path $rootFolder $destinationFolder From 14ca7021c13501d193283cf30740c470227aafbd Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Tue, 1 Sep 2026 19:40:56 +0200 Subject: [PATCH 19/34] Fix path comparisons with OS specific casings --- .../CheckForUpdates.HelperFunctions.ps1 | 75 +++-- Tests/CheckForUpdates.Action.Test.ps1 | 277 +++++++++++++++++- 2 files changed, 325 insertions(+), 27 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index dc99a8f852..c5e080b8ca 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -772,6 +772,24 @@ function UpdateSettingsFile { return $modified } +function GetPathStringComparison { + if ($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { + return [System.StringComparison]::Ordinal + } + else { + return [System.StringComparison]::OrdinalIgnoreCase + } +} + +function GetPathStringComparer { + if ((GetPathStringComparison) -eq [System.StringComparison]::Ordinal) { + return [System.StringComparer]::Ordinal + } + else { + return [System.StringComparer]::OrdinalIgnoreCase + } +} + <# .SYNOPSIS Resolves file paths based on the provided source folder, destination folder, and file specifications. @@ -833,12 +851,11 @@ function ResolveFilePaths { $destinationFolder = [System.IO.Path]::GetFullPath($destinationFolder) # Canonicalize the destination folder to an absolute path $destinationFolder = Join-Path $destinationFolder '' # Ensure destination folder has a trailing slash for correct path resolution - $pathComparison = [System.StringComparison]::OrdinalIgnoreCase - if ($PSVersionTable.PSVersion.Major -ge 6 -and ($IsLinux -or $IsMacOS)) { - $pathComparison = [System.StringComparison]::Ordinal - } + $pathComparison = GetPathStringComparison + $pathComparer = GetPathStringComparer $fullFilePaths = @() + $destinationFullPaths = [System.Collections.Generic.HashSet[string]]::new($pathComparer) foreach($file in $files) { if($file.Keys -notcontains 'sourceFolder') { $file.sourceFolder = '' # Default to current folder @@ -900,12 +917,12 @@ function ResolveFilePaths { # Try to find the same files in the original template folder if it is specified. Exclude custom template files if ($originalSourceFolder -and ($file.origin -ne 'custom template')) { - Push-Location $sourceFolder - $relativePath = Resolve-Path -Path $srcFile -Relative # resolve the path relative to the current location (template folder) - Pop-Location - if (Test-Path (Join-Path $originalSourceFolder $relativePath) -PathType Leaf) { + $relativeSourceFile = $srcFile.Substring($sourceFolder.Length) + $originalSourceFile = Join-Path $originalSourceFolder $relativeSourceFile + $originalSourceFile = [System.IO.Path]::GetFullPath($originalSourceFile) + if (Test-Path -LiteralPath $originalSourceFile -PathType Leaf) { # If the file exists in the original template folder, use that file instead - $fullFilePath.originalSourceFullPath = Join-Path $originalSourceFolder $relativePath -Resolve + $fullFilePath.originalSourceFullPath = $originalSourceFile } } @@ -923,10 +940,19 @@ function ResolveFilePaths { $project = '' # If project is '.', it means the root folder, so we use an empty string } - $fileDestinationFolder = Join-Path $destinationFolder $project - $fileDestinationFolder = Join-Path $fileDestinationFolder $file.destinationFolder + $projectDestinationFolder = Join-Path $destinationFolder $project + $projectDestinationFolder = Join-Path $projectDestinationFolder '' # Ensure project destination folder has a trailing slash for correct path resolution + + $fileDestinationFolder = Join-Path $projectDestinationFolder $file.destinationFolder + $fileDestinationFolder = [System.IO.Path]::GetFullPath($fileDestinationFolder) # Canonicalize the file destination folder to an absolute path $fileDestinationFolder = Join-Path $fileDestinationFolder '' # Ensure file destination folder has a trailing slash for correct path resolution + # Check if the destination folder is under the project destination folder + if (-not $fileDestinationFolder.StartsWith($projectDestinationFolder, $pathComparison)) { + OutputWarning "Skipping file '$srcFile' for project '$project': destination folder '$fileDestinationFolder' is outside the project destination folder '$projectDestinationFolder'." + continue + } + $fullProjectFilePath = $fullFilePath.Clone() $fullProjectFilePath.destinationFullPath = Join-Path $fileDestinationFolder $destinationName $fullProjectFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullProjectFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path @@ -937,7 +963,7 @@ function ResolveFilePaths { continue } - if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullProjectFilePath.destinationFullPath) { + if(-not $destinationFullPaths.Add($fullProjectFilePath.destinationFullPath)) { OutputDebug "Skipping duplicate per-project file for project '$project': destinationFullPath '$($fullProjectFilePath.destinationFullPath)' already exists" continue } @@ -950,8 +976,15 @@ function ResolveFilePaths { # Destination full path is the destination base folder + destinationFolder + destinationName $fileDestinationFolder = Join-Path $destinationFolder $file.destinationFolder + $fileDestinationFolder = [System.IO.Path]::GetFullPath($fileDestinationFolder) # Canonicalize the file destination folder to an absolute path $fileDestinationFolder = Join-Path $fileDestinationFolder '' # Ensure file destination folder has a trailing slash for correct path resolution + # Check if the destination folder is under the base destination folder + if (-not $fileDestinationFolder.StartsWith($destinationFolder, $pathComparison)) { + OutputWarning "Skipping file '$srcFile': destination folder '$fileDestinationFolder' is outside the destination folder '$destinationFolder'." + continue + } + $fullFilePath.destinationFullPath = Join-Path $fileDestinationFolder $destinationName $fullFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path @@ -961,7 +994,7 @@ function ResolveFilePaths { continue } - if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullFilePath.destinationFullPath) { + if(-not $destinationFullPaths.Add($fullFilePath.destinationFullPath)) { OutputDebug "Skipping duplicate file: destinationFullPath '$($fullFilePath.destinationFullPath)' already exists" continue } @@ -1130,6 +1163,9 @@ function GetFilesToUpdate { if ($settings.customALGoFiles.filesToExclude.Count -gt 0) { Trace-Information -Message "Usage: Custom AL-Go Files (Exclude)" } + + $pathComparer = GetPathStringComparer + # Determine files to include $filesToIncludeUnresolved = GetDefaultFilesToInclude -includeCustomTemplateFiles:$hasOriginalTemplate $filesToIncludeUnresolved += $settings.customALGoFiles.filesToInclude @@ -1138,7 +1174,8 @@ function GetFilesToUpdate { $filesToInclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) } # Deduplicate files to include based on destinationFullPath, keeping the first one (default > settings; template folder > original template folder) - $filesToInclude = @($filesToInclude | Group-Object { $_.destinationFullPath } | Sort-Object -Property Name | ForEach-Object { $_.Group[0] }) + $filesToIncludeDestinationFullPaths = [System.Collections.Generic.HashSet[string]]::new($pathComparer) + $filesToInclude = @($filesToInclude | Where-Object { $filesToIncludeDestinationFullPaths.Add($_.destinationFullPath) }) # Determine files to exclude $filesToExcludeUnresolved = GetDefaultFilesToExclude -settings $settings @@ -1154,29 +1191,31 @@ function GetFilesToUpdate { # Settings for filesToExclude only define the sources (sourceFolder and filter) but not the destinations (destinationFolder, destinationName and perProject) $filesToExclude = @($filesToInclude | Where-Object { $fileToInclude = $_ - return $filesToExclude | Where-Object { $_.sourceFullPath -eq $fileToInclude.sourceFullPath } + return $filesToExclude | Where-Object { $pathComparer.Equals($_.sourceFullPath, $fileToInclude.sourceFullPath) } }) # Exclude files from filesToInclude that are in filesToExclude (based on source) $filesToInclude = @($filesToInclude | Where-Object { $file = $_ - $include = -not ($filesToExclude | Where-Object { $_.sourceFullPath -eq $file.sourceFullPath }) + $include = -not ($filesToExclude | Where-Object { $pathComparer.Equals($_.sourceFullPath, $file.sourceFullPath) }) if (-not $include) { OutputDebug "Excluding source file '$($file.sourceFullPath)' from include list as it is in the exclude list" } return $include }) # Apply unusedALGoSystemFiles logic $unusedALGoSystemFiles = $settings.unusedALGoSystemFiles + $unusedALGoSystemFileNames = [System.Collections.Generic.HashSet[string]]::new($pathComparer) + $unusedALGoSystemFileNames.UnionWith([string[]]$unusedALGoSystemFiles) # Exclude unusedALGoSystemFiles from $filesToInclude and add them to $filesToExclude - $unusedFilesToExclude = $filesToInclude | Where-Object { $unusedALGoSystemFiles -contains (Split-Path -Path $_.sourceFullPath -Leaf) } + $unusedFilesToExclude = $filesToInclude | Where-Object { $unusedALGoSystemFileNames.Contains((Split-Path -Path $_.sourceFullPath -Leaf)) } if ($unusedFilesToExclude) { Trace-DeprecationWarning "The 'unusedALGoSystemFiles' setting is deprecated and will be removed in future versions." -DeprecationTag "unusedALGoSystemFiles" OutputDebug "The following files are marked as unused and will be removed if they exist:" $unusedFilesToExclude | ForEach-Object { OutputDebug "- $($_.destinationFullPath)" } - $filesToInclude = @($filesToInclude | Where-Object { $unusedALGoSystemFiles -notcontains (Split-Path -Path $_.sourceFullPath -Leaf) }) + $filesToInclude = @($filesToInclude | Where-Object { -not $unusedALGoSystemFileNames.Contains((Split-Path -Path $_.sourceFullPath -Leaf)) }) $filesToExclude += @($unusedFilesToExclude) } diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index def629004a..a33fe72caf 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -1357,8 +1357,27 @@ Describe "ResolveFilePaths" { $fullFilePaths.Count | Should -Be 1 $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File4.md") $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "project/folder/File4.md") - Should -Invoke OutputWarning -Times 3 -ParameterFilter { $message -like "*outside the destination folder '$destinationProjectFolder*" } + Should -Invoke OutputWarning -Times 2 -ParameterFilter { $message -like "*outside the destination folder '$destinationProjectFolder*" } Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the destination folder '$destinationProjectSubfolder*" } + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the project destination folder '$destinationProjectFolder*" } + } + + It 'ResolveFilePaths warns and skips per-project path outside the destination folder' -TestCases @( + @{ project = ".." } + @{ project = "project/.." } + ) { + param($project) + + $destinationFolder = Join-Path $rootFolder "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "perProject" = $true } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @($project)) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*for project '$project': destination folder*outside the project destination folder*" } } It 'ResolveFilePaths with type' { @@ -1555,7 +1574,7 @@ Describe "ResolveFilePaths" { if (Test-Path $externalFolder) { Remove-Item -Path $externalFolder -Recurse -Force } } - It 'ResolveFilePaths skips files in a source folder that differs only by case' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or $IsWindows) { + It 'ResolveFilePaths skips files in a source folder that differs only by case' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { $externalFolder = Join-Path $rootFolder 'sourcefolder' $externalFile = Join-Path $externalFolder 'outside.txt' New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null @@ -1576,7 +1595,7 @@ Describe "ResolveFilePaths" { } } - It 'ResolveFilePaths skips destinations in a folder that differs only by case' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or $IsWindows) { + It 'ResolveFilePaths skips destinations in a folder that differs only by case' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { $destinationFolder = Join-Path $rootFolder 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = '../casefolder/outside.txt' } @@ -1589,7 +1608,7 @@ Describe "ResolveFilePaths" { Should -Invoke OutputWarning -Times 1 } - It 'ResolveFilePaths skips per-project destinations in a folder that differs only by case' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or $IsWindows) { + It 'ResolveFilePaths skips per-project destinations in a folder that differs only by case' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { $destinationFolder = Join-Path $rootFolder 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = ''; 'destinationName' = '../caseproject/outside.txt'; 'perProject' = $true } @@ -1669,6 +1688,34 @@ Describe "ResolveFilePaths" { $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") } + It 'ResolveFilePaths keeps case-distinct destination entries on case-sensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { + $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt' } + @{ 'sourceFolder' = 'folder'; 'filter' = 'File2.log'; 'destinationFolder' = 'casefolder'; 'destinationName' = 'conflict.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 2 + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'CaseFolder/conflict.txt')) | Should -BeTrue + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'casefolder/conflict.txt')) | Should -BeTrue + } + + It 'ResolveFilePaths removes case-distinct destination entries on case-insensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { + $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt' } + @{ 'sourceFolder' = 'folder'; 'filter' = 'File2.log'; 'destinationFolder' = 'casefolder'; 'destinationName' = 'conflict.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 1 + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'CaseFolder/conflict.txt')) | Should -BeTrue + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'casefolder/conflict.txt')) | Should -BeFalse + } + It 'ResolveFilePaths treats dot project as repository root for per-project files' { $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" $files = @( @@ -1821,6 +1868,34 @@ Describe "ResolveFilePaths" { $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectA/folder/File1.txt") } + It 'ResolveFilePaths keeps case-distinct per-project destination entries on case-sensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { + $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } + @{ 'sourceFolder' = 'folder'; 'filter' = 'File2.log'; 'destinationFolder' = 'casefolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('ProjectA')) + + $fullFilePaths.Count | Should -Be 2 + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/CaseFolder/conflict.txt')) | Should -BeTrue + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/casefolder/conflict.txt')) | Should -BeTrue + } + + It 'ResolveFilePaths removes case-distinct per-project destination entries on case-insensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { + $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } + @{ 'sourceFolder' = 'folder'; 'filter' = 'File2.log'; 'destinationFolder' = 'casefolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('ProjectA')) + + $fullFilePaths.Count | Should -Be 1 + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/CaseFolder/conflict.txt')) | Should -BeTrue + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/casefolder/conflict.txt')) | Should -BeFalse + } + It 'ResolveFilePaths handles empty sourceFolder value' { # Create a file in the root of the sourceFolder $rootFile = Join-Path $sourceFolder "RootFile.txt" @@ -1891,6 +1966,28 @@ Describe "ResolveFilePaths" { } } + It 'ResolveFilePaths resolves original paths containing wildcard characters literally' { + $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' + $sourceFile = Join-Path $sourceFolder 'folder/File[1].ps1' + $originalSourceFile = Join-Path $originalSourceFolder 'folder/File[1].ps1' + Set-Content -LiteralPath $sourceFile -Value '# source file' + Set-Content -LiteralPath $originalSourceFile -Value '# original source file' + $currentLocation = Get-Location + + try { + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files @(@{ sourceFolder = 'folder'; filter = '*.ps1' }) -destinationFolder $destinationFolder -originalSourceFolder $originalSourceFolder) + $resolvedFile = @($fullFilePaths | Where-Object { $_.sourceFullPath -eq $sourceFile }) + + $resolvedFile.Count | Should -Be 1 + $resolvedFile[0].originalSourceFullPath | Should -Be $originalSourceFile + (Get-Location).Path | Should -Be $currentLocation.Path + } + finally { + Remove-Item -LiteralPath $sourceFile -Force + Remove-Item -LiteralPath $originalSourceFile -Force + } + } + It 'ResolveFilePaths with origin custom template and no originalSourceFolder skips files' { $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" $files = @( @@ -2481,8 +2578,8 @@ Describe "GetFilesToUpdate (general files to update logic)" { # test.txt should be in filesToExclude two times with different destinations $testTxtFiles = $filesToExclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") } $testTxtFiles.Count | Should -Be 2 - $testTxtFiles[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.renamed.txt') - $testTxtFiles[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.txt') + $testTxtFiles[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.txt') + $testTxtFiles[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.renamed.txt') } It 'GetFilesToUpdate handles overlapping include patterns with different destinations' { @@ -2527,6 +2624,168 @@ Describe "GetFilesToUpdate (general files to update logic)" { $conflict[0].sourceFullPath | Should -Be $testPSFile } + It 'GetFilesToUpdate keeps case-distinct destination entries on case-sensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @( + @{ filter = 'test.ps1'; destinationFolder = 'CaseFolder'; destinationName = 'conflict.txt' } + @{ filter = 'test.txt'; destinationFolder = 'casefolder'; destinationName = 'conflict.txt' } + ) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'CaseFolder/conflict.txt')) | Should -BeTrue + ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'casefolder/conflict.txt')) | Should -BeTrue + } + + It 'GetFilesToUpdate excludes only the exact-case source path on case-sensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { + $upperCaseFolder = Join-Path $templateFolder 'CaseFolder' + $lowerCaseFolder = Join-Path $templateFolder 'casefolder' + $upperCaseFile = Join-Path $upperCaseFolder 'script.ps1' + $lowerCaseFile = Join-Path $lowerCaseFolder 'script.ps1' + New-Item -ItemType Directory -Path $upperCaseFolder -Force | Out-Null + New-Item -ItemType Directory -Path $lowerCaseFolder -Force | Out-Null + Set-Content -Path $upperCaseFile -Value '# upper case folder' + Set-Content -Path $lowerCaseFile -Value '# lower case folder' + + try { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @( + @{ sourceFolder = 'CaseFolder'; filter = 'script.ps1' } + @{ sourceFolder = 'casefolder'; filter = 'script.ps1' } + ) + filesToExclude = @(@{ sourceFolder = 'casefolder'; filter = 'script.ps1' }) + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToInclude.Count | Should -Be 1 + $filesToInclude[0].sourceFullPath | Should -BeExactly $upperCaseFile + $filesToExclude.Count | Should -Be 1 + $filesToExclude[0].sourceFullPath | Should -BeExactly $lowerCaseFile + } + finally { + Remove-Item -Path $upperCaseFolder -Recurse -Force + Remove-Item -Path $lowerCaseFolder -Recurse -Force + } + } + + It 'GetFilesToUpdate excludes only the exact-case unused file on case-sensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { + $upperCaseFile = Join-Path $templateFolder 'UnusedFile.ps1' + $lowerCaseFile = Join-Path $templateFolder 'unusedfile.ps1' + Set-Content -Path $upperCaseFile -Value '# upper case file' + Set-Content -Path $lowerCaseFile -Value '# lower case file' + + try { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @('unusedfile.ps1') + customALGoFiles = @{ + filesToInclude = @(@{ filter = '*.ps1' }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToInclude.sourceFullPath -ccontains $upperCaseFile | Should -BeTrue + $filesToInclude.sourceFullPath -ccontains $lowerCaseFile | Should -BeFalse + $filesToExclude.sourceFullPath -ccontains $upperCaseFile | Should -BeFalse + $filesToExclude.sourceFullPath -ccontains $lowerCaseFile | Should -BeTrue + } + finally { + Remove-Item -Path $upperCaseFile -Force + Remove-Item -Path $lowerCaseFile -Force + } + } + + It 'GetFilesToUpdate removes case-distinct destination entries on case-insensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @( + @{ filter = 'test.ps1'; destinationFolder = 'CaseFolder'; destinationName = 'conflict.txt' } + @{ filter = 'test.txt'; destinationFolder = 'casefolder'; destinationName = 'conflict.txt' } + ) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'CaseFolder/conflict.txt')) | Should -BeTrue + ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'casefolder/conflict.txt')) | Should -BeFalse + } + + It 'GetFilesToUpdate excludes any case source path on case-insensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { + $upperCaseFolder = Join-Path $templateFolder 'CaseFolder' + $lowerCaseFolder = Join-Path $templateFolder 'casefolder' + $upperCaseFile = Join-Path $upperCaseFolder 'script.ps1' + $lowerCaseFile = Join-Path $lowerCaseFolder 'script.ps1' + New-Item -ItemType Directory -Path $upperCaseFolder -Force | Out-Null + Set-Content -Path $upperCaseFile -Value '# upper case folder' + + try { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @( + @{ sourceFolder = 'CaseFolder'; filter = 'script.ps1' } + @{ sourceFolder = 'casefolder'; filter = 'script.ps1' } + ) + filesToExclude = @(@{ sourceFolder = 'casefolder'; filter = 'script.ps1' }) + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToInclude.Count | Should -Be 0 + $filesToExclude.Count | Should -Be 1 + $filesToExclude[0].sourceFullPath | Should -BeExactly $upperCaseFile + } + finally { + Remove-Item -Path $upperCaseFolder -Recurse -Force + } + } + + It 'GetFilesToUpdate excludes any case unused file on case-insensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { + $upperCaseFile = Join-Path $templateFolder 'UnusedFile.ps1' + $lowerCaseFile = Join-Path $templateFolder 'unusedfile.ps1' + Set-Content -Path $upperCaseFile -Value '# upper case file' + + try { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @('unusedfile.ps1') + customALGoFiles = @{ + filesToInclude = @(@{ filter = '*.ps1' }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToInclude.sourceFullPath -ccontains $upperCaseFile | Should -BeFalse + $filesToInclude.sourceFullPath -ccontains $lowerCaseFile | Should -BeFalse + $filesToExclude.sourceFullPath -ccontains $upperCaseFile | Should -BeTrue + $filesToExclude.sourceFullPath -ccontains $lowerCaseFile | Should -BeFalse + } + finally { + Remove-Item -Path $upperCaseFile -Force + } + } + It 'GetFilesToUpdate filesToInclude includes original template files missing in template' { $settings = @{ type = "NotPTE" @@ -2838,9 +3097,9 @@ Describe "GetFilesToUpdate (real template)" { $filesToExclude | Should -Not -BeNullOrEmpty $filesToExclude.Count | Should -Be $powerPlatformFiles.Count - for ($i = 0; $i -lt $powerPlatformFiles.Count; $i++) { - $filesToExclude[$i].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder $powerPlatformFiles[$i]) - $filesToExclude[$i].destinationFullPath | Should -Be (Join-Path $baseFolder $powerPlatformFiles[$i]) + $powerPlatformFiles | ForEach-Object { + $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $_) + $filesToExclude.destinationFullPath | Should -Contain (Join-Path $baseFolder $_) } } From 95d9cf476ee880eb66bbba020cc771683fe57eb9 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Tue, 15 Sep 2026 21:31:00 +0200 Subject: [PATCH 20/34] Resolve symlinks/junctions --- .../CheckForUpdates.HelperFunctions.ps1 | 102 ++- Tests/CheckForUpdates.Action.Test.ps1 | 635 ++++++++++++++++-- 2 files changed, 667 insertions(+), 70 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index c5e080b8ca..aa6fbe3100 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -790,6 +790,79 @@ function GetPathStringComparer { } } +<# +.SYNOPSIS +Checks whether a path is physically contained within a root folder, resolving symlinks/junctions. +.DESCRIPTION +Verifies that $Path is located under $RootFolder both lexically and after resolving any +symbolic links or junctions along the way, protecting against paths that escape the root +folder via reparse points. Both parameters are treated as literal paths (no wildcard expansion). +.PARAMETER Path +The literal path to check. +.PARAMETER RootFolder +The literal root folder that $Path must be contained within. +.OUTPUTS +$true if the path is physically contained within the root folder, otherwise $false. +#> +function Test-PathPhysicallyContained { + Param( + [Parameter(Mandatory=$true)] + [string] $Path, + [Parameter(Mandatory=$true)] + [string] $RootFolder + ) + + $pathComparison = GetPathStringComparison + $RootFolder = Join-Path $RootFolder '' # ensure the root folder path ends with a directory separator + + # Early exit if the path is obviously outside the root folder lexically + if (-not $Path.StartsWith($RootFolder, $pathComparison)) { + return $false + } + + $realPath = $RootFolder + $resolveReparsePoints = $true # once an ancestor doesn't exist, no deeper segment can be a reparse point either + $hopLimit = 40 # matches the classic OS/.NET max-followed-symlinks limit (guards against cyclic chains) + $hopCount = 0 + + # Split the relative path into individual segments for iterative resolution + $segments = $Path.Substring($RootFolder.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + foreach ($segment in $segments) { + $realPath = Join-Path $realPath $segment + + # Follow a possible chain of reparse points (link -> link -> real) until fully resolved + while ($true) { + if (-not $resolveReparsePoints) { + break + } + if (-not (Test-Path -LiteralPath $realPath)) { + $resolveReparsePoints = $false + break + } + + $item = Get-Item -LiteralPath $realPath -Force + if ($item.LinkType -notin @('SymbolicLink', 'Junction') -or -not $item.Target) { + break + } + if ($hopCount++ -gt $hopLimit) { + # Cyclic or pathologically deep chain - fail closed, like the OS would refuse to resolve it too + OutputWarning "Path '$Path' could not be resolved: reparse point chain exceeded $($hopLimit) hops (cyclic or too deep) at '$realPath'. Treating as not contained." + return $false + } + + $target = @($item.Target)[0] + if (-not [System.IO.Path]::IsPathRooted($target)) { + $target = Join-Path (Split-Path -Path $realPath -Parent) $target + } + $realPath = [System.IO.Path]::GetFullPath($target) + } + } + + $realPath = [System.IO.Path]::GetFullPath($realPath) + + return $realPath.StartsWith($RootFolder, $pathComparison) +} + <# .SYNOPSIS Resolves file paths based on the provided source folder, destination folder, and file specifications. @@ -798,6 +871,7 @@ Resolves file paths based on the provided source folder, destination folder, and This function takes a source folder, an optional original source folder, a destination folder, and an array of file specifications. It resolves the full paths for each specified file, considering their origin (template or custom template), type, and whether they are per-project files. The function returns an array of hashtables containing the resolved source and destination file paths. The function is used to determine which files need to be copied from the template repository to the target repository during the AL-Go update process. +Destination boundary checks are symlink/junction-aware (see Test-PathPhysicallyContained): a destination path is only accepted if it is both lexically and physically (after resolving reparse points) contained within the destination folder. sourceFolder: The base folder of the template used to resolve the source file paths. originalSourceFolder: The base folder of the original template used to check for original files (can be $null). This is in the case of custom templates, where if the file exists in the original template, it should be used instead of the custom template file. destinationFolder: The base folder used to construct the destination file paths. This is typically the root folder of the target repository. @@ -851,7 +925,6 @@ function ResolveFilePaths { $destinationFolder = [System.IO.Path]::GetFullPath($destinationFolder) # Canonicalize the destination folder to an absolute path $destinationFolder = Join-Path $destinationFolder '' # Ensure destination folder has a trailing slash for correct path resolution - $pathComparison = GetPathStringComparison $pathComparer = GetPathStringComparer $fullFilePaths = @() @@ -907,8 +980,8 @@ function ResolveFilePaths { 'destinationFullPath' = $null } - # Check if the source file is under the source folder - if (-not $srcFile.StartsWith($sourceFolder, $pathComparison)) { + # Check if the source file is under the source folder (symlink/junction-aware) + if (-not (Test-PathPhysicallyContained -Path $srcFile -RootFolder $sourceFolder)) { OutputDebug "Skipping source file '$($srcFile)' as it is not under the source folder '$($sourceFolder)'." continue } @@ -942,13 +1015,14 @@ function ResolveFilePaths { $projectDestinationFolder = Join-Path $destinationFolder $project $projectDestinationFolder = Join-Path $projectDestinationFolder '' # Ensure project destination folder has a trailing slash for correct path resolution + # Do not canonicalize the project destination folder to an absolute path, or "dest/foo/bar/../" would become the valid project folder "dest/foo/" and potentially allow files to escape the intended project folder. $fileDestinationFolder = Join-Path $projectDestinationFolder $file.destinationFolder $fileDestinationFolder = [System.IO.Path]::GetFullPath($fileDestinationFolder) # Canonicalize the file destination folder to an absolute path $fileDestinationFolder = Join-Path $fileDestinationFolder '' # Ensure file destination folder has a trailing slash for correct path resolution - # Check if the destination folder is under the project destination folder - if (-not $fileDestinationFolder.StartsWith($projectDestinationFolder, $pathComparison)) { + # Check if the destination folder is under the project destination folder (symlink/junction-aware) + if (-not (Test-PathPhysicallyContained -Path $fileDestinationFolder -RootFolder $projectDestinationFolder)) { OutputWarning "Skipping file '$srcFile' for project '$project': destination folder '$fileDestinationFolder' is outside the project destination folder '$projectDestinationFolder'." continue } @@ -957,9 +1031,9 @@ function ResolveFilePaths { $fullProjectFilePath.destinationFullPath = Join-Path $fileDestinationFolder $destinationName $fullProjectFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullProjectFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path - # Check if the destination file is under the file destination folder - if (-not $fullProjectFilePath.destinationFullPath.StartsWith($fileDestinationFolder, $pathComparison)) { - OutputWarning "Skipping file '$srcFile' for project '$project': destination file '$($fullProjectFilePath.destinationFullPath)' is outside the destination folder '$fileDestinationFolder'." + # Check if the destination file is under the file destination folder (symlink/junction-aware) + if (-not (Test-PathPhysicallyContained -Path $fullProjectFilePath.destinationFullPath -RootFolder $fileDestinationFolder)) { + OutputWarning "Skipping file '$srcFile' for project '$project': destination file '$($fullProjectFilePath.destinationFullPath)' is outside the file destination folder '$fileDestinationFolder'." continue } @@ -979,18 +1053,18 @@ function ResolveFilePaths { $fileDestinationFolder = [System.IO.Path]::GetFullPath($fileDestinationFolder) # Canonicalize the file destination folder to an absolute path $fileDestinationFolder = Join-Path $fileDestinationFolder '' # Ensure file destination folder has a trailing slash for correct path resolution - # Check if the destination folder is under the base destination folder - if (-not $fileDestinationFolder.StartsWith($destinationFolder, $pathComparison)) { - OutputWarning "Skipping file '$srcFile': destination folder '$fileDestinationFolder' is outside the destination folder '$destinationFolder'." + # Check if the destination folder is under the base destination folder (symlink/junction-aware) + if (-not (Test-PathPhysicallyContained -Path $fileDestinationFolder -RootFolder $destinationFolder)) { + OutputWarning "Skipping file '$srcFile': destination folder '$fileDestinationFolder' is outside the base destination folder '$destinationFolder'." continue } $fullFilePath.destinationFullPath = Join-Path $fileDestinationFolder $destinationName $fullFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path - # Check if the destination file is under the file destination folder - if (-not $fullFilePath.destinationFullPath.StartsWith($fileDestinationFolder, $pathComparison)) { - OutputWarning "Skipping file '$srcFile': destination file '$($fullFilePath.destinationFullPath)' is outside the destination folder '$fileDestinationFolder'." + # Check if the destination file is under the file destination folder (symlink/junction-aware) + if (-not (Test-PathPhysicallyContained -Path $fullFilePath.destinationFullPath -RootFolder $fileDestinationFolder)) { + OutputWarning "Skipping file '$srcFile': destination file '$($fullFilePath.destinationFullPath)' is outside the file destination folder '$fileDestinationFolder'." continue } diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index a33fe72caf..8f0e03de1b 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -4,6 +4,28 @@ Import-Module (Join-Path $PSScriptRoot "../Actions/TelemetryHelper.psm1") Import-Module (Join-Path $PSScriptRoot '../Actions/.Modules/ReadSettings.psm1') $errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0 +# Computed here (not in BeforeAll) because -Skip: expressions are evaluated at discovery time, and +# these variables are used by -Skip: expressions in Describe blocks throughout this file. +# $IsWindows doesn't exist in Windows PowerShell 5.1, which only ever runs on Windows anyway. +$script:isWindowsPlatform = ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows +$script:isLinuxPlatform = ($PSVersionTable.PSVersion.Major -ge 6) -and $IsLinux + +# Determine if the runner has the capability to create symlinks +$script:hasSymlinkCapability = $true +if ($script:isWindowsPlatform) { + # Probe once whether this runner can create symlinks; Junctions never need this privilege, SymbolicLinks do + $probeLinkPath = Join-Path ([System.IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString()) + try { + New-Item -ItemType SymbolicLink -Path $probeLinkPath -Target $PSScriptRoot -ErrorAction Stop | Out-Null + } + catch { + $script:hasSymlinkCapability = $false + } + finally { + Remove-Item -Path $probeLinkPath -Recurse -Force -ErrorAction SilentlyContinue + } +} + Describe "CheckForUpdates Action Tests" { BeforeAll { $actionName = "CheckForUpdates" @@ -1336,8 +1358,9 @@ Describe "ResolveFilePaths" { $fullFilePaths.Count | Should -Be 1 $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File4.md") $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File4.md") - Should -Invoke OutputWarning -Times 3 -ParameterFilter { $message -like "*outside the destination folder '$destinationFolder*" } - Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the destination folder '$destinationSubfolder*" } + Should -Invoke OutputWarning -Times 2 -ParameterFilter { $message -like "*outside the file destination folder '$destinationFolder*" } + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the file destination folder '$destinationSubfolder*" } + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the base destination folder '$destinationFolder*" } } It 'ResolveFilePaths warns and skips per-project destinations outside the destination folder' { @@ -1357,8 +1380,8 @@ Describe "ResolveFilePaths" { $fullFilePaths.Count | Should -Be 1 $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File4.md") $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "project/folder/File4.md") - Should -Invoke OutputWarning -Times 2 -ParameterFilter { $message -like "*outside the destination folder '$destinationProjectFolder*" } - Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the destination folder '$destinationProjectSubfolder*" } + Should -Invoke OutputWarning -Times 2 -ParameterFilter { $message -like "*outside the file destination folder '$destinationProjectFolder*" } + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the file destination folder '$destinationProjectSubfolder*" } Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the project destination folder '$destinationProjectFolder*" } } @@ -1377,7 +1400,7 @@ Describe "ResolveFilePaths" { $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @($project)) $fullFilePaths | Should -BeNullOrEmpty - Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*for project '$project': destination folder*outside the project destination folder*" } + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*for project '$project': destination folder*outside the * destination folder*" } } It 'ResolveFilePaths with type' { @@ -1526,62 +1549,60 @@ Describe "ResolveFilePaths" { } It 'ResolveFilePaths skips files outside the source folder' { - # Create an external file outside the source folder $externalFolder = Join-Path $PSScriptRoot "external" - if (-not (Test-Path $externalFolder)) { New-Item -Path $externalFolder -ItemType Directory | Out-Null } $externalFile = Join-Path $externalFolder "outside.txt" - Set-Content -Path $externalFile -Value "outside" - - $destinationFolder = "destinationFolder" - $destinationFolder = Join-Path $PSScriptRoot $destinationFolder - - $files = @( - @{ "sourceFolder" = "../external"; "filter" = "*.txt" } - ) + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + Set-Content -Path $externalFile -Value "outside" - # Intentionally call ResolveFilePaths with the real sourceFolder (so external file should not be included) - $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder + $files = @( + @{ "sourceFolder" = "../external"; "filter" = "*.txt" } + ) - # Ensure none of the returned sourceFullPath entries point to the external file - $fullFilePaths | ForEach-Object { $_.sourceFullPath | Should -Not -Be $externalFile } + # Intentionally call ResolveFilePaths with the real sourceFolder (so external file should not be included) + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder - # Cleanup - if (Test-Path $externalFile) { Remove-Item -Path $externalFile -Force } - if (Test-Path $externalFolder) { Remove-Item -Path $externalFolder -Recurse -Force } + # Ensure none of the returned sourceFullPath entries point to the external file + $fullFilePaths | ForEach-Object { $_.sourceFullPath | Should -Not -Be $externalFile } + } + finally { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + } } It 'ResolveFilePaths skips files in folder whose name starts with source folder name' { - # Create an external file in a folder whose name starts with the same prefix as sourceFolder $externalFolder = "${sourceFolder}-external" - if (-not (Test-Path $externalFolder)) { New-Item -Path $externalFolder -ItemType Directory | Out-Null } $externalFile = Join-Path $externalFolder "outside.txt" - Set-Content -Path $externalFile -Value "outside" - - $destinationFolder = "destinationFolder" - $destinationFolder = Join-Path $PSScriptRoot $destinationFolder + $destinationFolder = Join-Path $rootFolder 'destinationFolder' - $files = @( - @{ "sourceFolder" = "../sourceFolder-external"; "filter" = "*.txt" } - ) + try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + Set-Content -Path $externalFile -Value "outside" - $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder + $files = @( + @{ "sourceFolder" = "../sourceFolder-external"; "filter" = "*.txt" } + ) - # The file in the prefix-colliding folder must NOT be included - $fullFilePaths | ForEach-Object { $_.sourceFullPath | Should -Not -BeLike "${externalFolder}*" } + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder - # Cleanup - if (Test-Path $externalFile) { Remove-Item -Path $externalFile -Force } - if (Test-Path $externalFolder) { Remove-Item -Path $externalFolder -Recurse -Force } + # The file in the prefix-colliding folder must NOT be included + $fullFilePaths | ForEach-Object { $_.sourceFullPath | Should -Not -BeLike "${externalFolder}*" } + } + finally { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + } } - It 'ResolveFilePaths skips files in a source folder that differs only by case' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { + It 'ResolveFilePaths skips files in a source folder that differs only by case' -Skip:(-not $script:isLinuxPlatform) { $externalFolder = Join-Path $rootFolder 'sourcefolder' $externalFile = Join-Path $externalFolder 'outside.txt' - New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null - Set-Content -Path $externalFile -Value 'outside' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' try { - $destinationFolder = Join-Path $rootFolder 'destinationFolder' + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + Set-Content -Path $externalFile -Value 'outside' + $files = @( @{ 'sourceFolder' = '../sourcefolder'; 'filter' = '*.txt' } ) @@ -1591,11 +1612,11 @@ Describe "ResolveFilePaths" { $fullFilePaths | Should -BeNullOrEmpty } finally { - if (Test-Path $externalFolder) { Remove-Item -Path $externalFolder -Recurse -Force } + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue } } - It 'ResolveFilePaths skips destinations in a folder that differs only by case' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { + It 'ResolveFilePaths skips destinations in a folder that differs only by case' -Skip:(-not $script:isLinuxPlatform) { $destinationFolder = Join-Path $rootFolder 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = '../casefolder/outside.txt' } @@ -1608,7 +1629,7 @@ Describe "ResolveFilePaths" { Should -Invoke OutputWarning -Times 1 } - It 'ResolveFilePaths skips per-project destinations in a folder that differs only by case' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { + It 'ResolveFilePaths skips per-project destinations in a folder that differs only by case' -Skip:(-not $script:isLinuxPlatform) { $destinationFolder = Join-Path $rootFolder 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = ''; 'destinationName' = '../caseproject/outside.txt'; 'perProject' = $true } @@ -1621,6 +1642,204 @@ Describe "ResolveFilePaths" { Should -Invoke OutputWarning -Times 1 } + It 'ResolveFilePaths skips destinations reachable only through a junction pointing outside the destination folder' -Skip:(-not $script:isWindowsPlatform) { + $externalFolder = Join-Path $rootFolder 'external' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $destinationLinkPath = Join-Path $destinationFolder 'externalLink' + + try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $destinationLinkPath -Target $externalFolder -Force | Out-Null + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'externalLink' } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 + } + finally { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths skips destinations reachable only through a symlink pointing outside the destination folder' -Skip:(-not $script:hasSymlinkCapability) { + $externalFolder = Join-Path $rootFolder 'external' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $destinationLinkPath = Join-Path $destinationFolder 'externalLink' + + try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $destinationLinkPath -Target $externalFolder -Force | Out-Null + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'externalLink' } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 + } + finally { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths resolves destinations reachable through a junction that stays within the destination folder' -Skip:(-not $script:isWindowsPlatform) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $destinationSubfolder = Join-Path $destinationFolder 'subfolder' + $destinationLinkPath = Join-Path $destinationFolder 'internalLink' + New-Item -Path $destinationSubfolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $destinationLinkPath -Target $destinationSubfolder -Force | Out-Null + + try { + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'internalLink' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalLink/File1.txt') + } + finally { + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths resolves destinations reachable through a symlink that stays within the destination folder' -Skip:(-not $script:hasSymlinkCapability) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $destinationSubfolder = Join-Path $destinationFolder 'subfolder' + $destinationLinkPath = Join-Path $destinationFolder 'internalLink' + New-Item -Path $destinationSubfolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $destinationLinkPath -Target $destinationSubfolder -Force | Out-Null + + try { + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'internalLink' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalLink/File1.txt') + } + finally { + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths skips source files reachable only through a junction pointing outside the source folder' -Skip:(-not $script:isWindowsPlatform) { + $externalFolder = Join-Path $rootFolder 'external' + $sourceFile = Join-Path $externalFolder 'File.txt' + $sourceLinkPath = Join-Path $sourceFolder 'externalLink' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + + try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + New-Item -Path $sourceFile -ItemType File -Force | Out-Null + New-Item -ItemType Junction -Path $sourceLinkPath -Target $externalFolder -Force | Out-Null + $files = @( + @{ 'sourceFolder' = 'externalLink'; 'filter' = '*.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -BeNullOrEmpty + } + finally { + Remove-Item -Path $sourceLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths skips source files reachable only through a symlink pointing outside the source folder' -Skip:(-not $script:hasSymlinkCapability) { + $externalFolder = Join-Path $rootFolder 'external' + $sourceFile = Join-Path $externalFolder 'File.txt' + $sourceLinkPath = Join-Path $sourceFolder 'externalLink' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + + try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + New-Item -Path $sourceFile -ItemType File -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $sourceLinkPath -Target $externalFolder -Force | Out-Null + $files = @( + @{ 'sourceFolder' = 'externalLink'; 'filter' = '*.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -BeNullOrEmpty + } + finally { + Remove-Item -Path $sourceLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths resolves source files reachable through a junction that stays within the source folder' -Skip:(-not $script:isWindowsPlatform) { + $sourceSubfolder = Join-Path $sourceFolder 'subfolder' + $sourceFile = Join-Path $sourceSubfolder 'File.txt' + $sourceLinkPath = Join-Path $sourceFolder 'internalLink' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + + try { + New-Item -Path $sourceSubfolder -ItemType Directory -Force | Out-Null + New-Item -Path $sourceFile -ItemType File -Force | Out-Null + New-Item -ItemType Junction -Path $sourceLinkPath -Target $sourceSubfolder -Force | Out-Null + + $files = @( + @{ 'sourceFolder' = 'internalLink'; 'filter' = '*.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder 'internalLink/File.txt') + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalLink/File.txt') + } + finally { + Remove-Item -Path $sourceLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $sourceSubfolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths resolves source files reachable through a symlink that stays within the source folder' -Skip:(-not $script:hasSymlinkCapability) { + $sourceSubfolder = Join-Path $sourceFolder 'subfolder' + $sourceFile = Join-Path $sourceSubfolder 'File.txt' + $sourceLinkPath = Join-Path $sourceFolder 'internalLink' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + + try { + New-Item -Path $sourceSubfolder -ItemType Directory -Force | Out-Null + New-Item -Path $sourceFile -ItemType File -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $sourceLinkPath -Target $sourceSubfolder -Force | Out-Null + + $files = @( + @{ 'sourceFolder' = 'internalLink'; 'filter' = '*.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder 'internalLink/File.txt') + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalLink/File.txt') + } + finally { + Remove-Item -Path $sourceLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $sourceSubfolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + It 'ResolveFilePaths returns empty when no files match filter' { $destinationFolder = "destinationFolder" $destinationFolder = Join-Path $rootFolder $destinationFolder @@ -1688,7 +1907,7 @@ Describe "ResolveFilePaths" { $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") } - It 'ResolveFilePaths keeps case-distinct destination entries on case-sensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { + It 'ResolveFilePaths keeps case-distinct destination entries on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt' } @@ -1702,7 +1921,7 @@ Describe "ResolveFilePaths" { ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'casefolder/conflict.txt')) | Should -BeTrue } - It 'ResolveFilePaths removes case-distinct destination entries on case-insensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { + It 'ResolveFilePaths removes case-distinct destination entries on case-insensitive platforms' -Skip:$script:isLinuxPlatform { $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt' } @@ -1868,7 +2087,7 @@ Describe "ResolveFilePaths" { $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectA/folder/File1.txt") } - It 'ResolveFilePaths keeps case-distinct per-project destination entries on case-sensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { + It 'ResolveFilePaths keeps case-distinct per-project destination entries on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } @@ -1882,7 +2101,7 @@ Describe "ResolveFilePaths" { ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/casefolder/conflict.txt')) | Should -BeTrue } - It 'ResolveFilePaths removes case-distinct per-project destination entries on case-insensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { + It 'ResolveFilePaths removes case-distinct per-project destination entries on case-insensitive platforms' -Skip:$script:isLinuxPlatform { $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } @@ -2033,6 +2252,312 @@ Describe "ResolveFilePaths" { } } +Describe "Test-PathPhysicallyContained" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + + $rootFolder = Join-Path $PSScriptRoot "physicallyContainedTests" + $externalFolder = Join-Path $PSScriptRoot "physicallyContainedTestsExternal" + New-Item -Path $rootFolder -ItemType Directory -Force | Out-Null + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + } + + AfterAll { + if (Test-Path $rootFolder) { + Remove-Item -Path $rootFolder -Recurse -Force -ErrorAction SilentlyContinue + } + if (Test-Path $externalFolder) { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true for a path lexically inside the root folder (no I/O involved)' { + $path = Join-Path $rootFolder "folder/file.txt" + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + + It 'Test-PathPhysicallyContained returns false for a path lexically outside the root folder (no I/O involved)' { + $path = Join-Path $externalFolder "file.txt" + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + + It 'Test-PathPhysicallyContained returns true when trailing path segments do not exist yet' { + $internalFolderPath = Join-Path $rootFolder "folder" + $path = Join-Path $internalFolderPath "subfolder/file.txt" + try { + New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + # Symbolic link tests (requires symlink capability) + + It 'Test-PathPhysicallyContained returns true through a single symlink that stays within the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $internalFolderPath -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $internalFolderPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false through a single symlink that points outside the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $externalFolder -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true through a chain of two symlinks landing inside the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath2 -Target $internalFolderPath -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath, $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true through a chain of two symlinks going outside but landing back inside the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath1 = Join-Path $rootFolder "link1" + $externalLinkPath2 = Join-Path $externalFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $externalLinkPath2 -Target $internalFolderPath -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target $externalLinkPath2 -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath, $internalLinkPath1, $externalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false through a chain of two symlinks landing outside the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -ItemType SymbolicLink -Path $internalLinkPath2 -Target $externalFolder -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true through a symlink with a relative target that stays within the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + Push-Location $rootFolder + New-Item -ItemType Directory -Path $internalFolderPath -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target "folder" -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath, $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Pop-Location + } + } + + It 'Test-PathPhysicallyContained returns false through a symlink with a relative target landing outside the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + Push-Location $rootFolder + New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target "../physicallyContainedTestsExternal" -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Pop-Location + } + } + + It 'Test-PathPhysicallyContained returns false and completes without hanging for a cyclic symlink pair' -Skip:(-not $script:hasSymlinkCapability) { + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -ItemType Directory -Path $internalLinkPath1 -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath2 -Target $internalLinkPath1 -Force | Out-Null + Remove-Item -Path $internalLinkPath1 -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null + Mock OutputWarning {} + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "Path '$path' could not be resolved: reparse point chain exceeded * hops (cyclic or too deep) at '*'. Treating as not contained." } + } + finally { + Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + # Junction tests (Windows only) + + It 'Test-PathPhysicallyContained returns true through a single junction that stays within the root' -Skip:(-not $script:isWindowsPlatform) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + New-Item -ItemType Directory -Path $internalFolderPath -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath -Target $internalFolderPath -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath, $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false through a single junction that points outside the root' -Skip:(-not $script:isWindowsPlatform) { + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + New-Item -ItemType Junction -Path $internalLinkPath -Target $externalFolder -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true through a chain of two junctions landing inside the root' -Skip:(-not $script:isWindowsPlatform) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -ItemType Directory -Path $internalFolderPath -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath2 -Target $internalFolderPath -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath,$internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true through a chain of two junctions going outside but landing back inside the root' -Skip:(-not $script:isWindowsPlatform) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath1 = Join-Path $rootFolder "link1" + $externalLinkPath2 = Join-Path $externalFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -ItemType Directory -Path $internalFolderPath -Force | Out-Null + New-Item -ItemType Junction -Path $externalLinkPath2 -Target $internalFolderPath -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath1 -Target $externalLinkPath2 -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath, $internalLinkPath1, $externalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false through a chain of two junctions landing outside the root' -Skip:(-not $script:isWindowsPlatform) { + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -ItemType Junction -Path $internalLinkPath2 -Target $externalFolder -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false and completes without hanging for a cyclic junction pair' -Skip:(-not $script:isWindowsPlatform) { + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -ItemType Directory -Path $internalLinkPath1 -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath2 -Target $internalLinkPath1 -Force | Out-Null + Remove-Item -Path $internalLinkPath1 -Recurse -Force + New-Item -ItemType Junction -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null + Mock OutputWarning {} + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "Path '$path' could not be resolved: reparse point chain exceeded * hops (cyclic or too deep) at '*'. Treating as not contained." } + } + finally { + Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + # Hard link tests (no elevation needed on any platform) + + It 'Test-PathPhysicallyContained returns true for a hard-linked file inside the root' { + $internalFilePath = Join-Path $rootFolder "file.txt" + $internalLinkPath = Join-Path $rootFolder "link.txt" + $path = $internalLinkPath + try { + New-Item -ItemType File -Path $internalFilePath -Force | Out-Null + New-Item -ItemType HardLink -Path $internalLinkPath -Target $internalFilePath -Force | Out-Null + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFilePath, $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true for a hard-linked file outside the root' { + $externalFilePath = Join-Path $externalFolder "file.txt" + $internalLinkPath = Join-Path $rootFolder "link.txt" + $path = $internalLinkPath + try { + New-Item -ItemType File -Path $externalFilePath -Force | Out-Null + New-Item -ItemType HardLink -Path $internalLinkPath -Target $externalFilePath -Force | Out-Null + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $externalFilePath, $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + Describe "ReplaceOwnerRepoAndBranch" { BeforeAll { $actionName = "CheckForUpdates" @@ -2624,7 +3149,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { $conflict[0].sourceFullPath | Should -Be $testPSFile } - It 'GetFilesToUpdate keeps case-distinct destination entries on case-sensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { + It 'GetFilesToUpdate keeps case-distinct destination entries on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { $settings = @{ type = 'NotPTE' unusedALGoSystemFiles = @() @@ -2643,7 +3168,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'casefolder/conflict.txt')) | Should -BeTrue } - It 'GetFilesToUpdate excludes only the exact-case source path on case-sensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { + It 'GetFilesToUpdate excludes only the exact-case source path on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { $upperCaseFolder = Join-Path $templateFolder 'CaseFolder' $lowerCaseFolder = Join-Path $templateFolder 'casefolder' $upperCaseFile = Join-Path $upperCaseFolder 'script.ps1' @@ -2679,7 +3204,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - It 'GetFilesToUpdate excludes only the exact-case unused file on case-sensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -lt 6 -or -not $IsLinux) { + It 'GetFilesToUpdate excludes only the exact-case unused file on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { $upperCaseFile = Join-Path $templateFolder 'UnusedFile.ps1' $lowerCaseFile = Join-Path $templateFolder 'unusedfile.ps1' Set-Content -Path $upperCaseFile -Value '# upper case file' @@ -2708,7 +3233,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - It 'GetFilesToUpdate removes case-distinct destination entries on case-insensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { + It 'GetFilesToUpdate removes case-distinct destination entries on case-insensitive platforms' -Skip:$script:isLinuxPlatform { $settings = @{ type = 'NotPTE' unusedALGoSystemFiles = @() @@ -2727,11 +3252,9 @@ Describe "GetFilesToUpdate (general files to update logic)" { ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'casefolder/conflict.txt')) | Should -BeFalse } - It 'GetFilesToUpdate excludes any case source path on case-insensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { + It 'GetFilesToUpdate excludes any case source path on case-insensitive platforms' -Skip:$script:isLinuxPlatform { $upperCaseFolder = Join-Path $templateFolder 'CaseFolder' - $lowerCaseFolder = Join-Path $templateFolder 'casefolder' $upperCaseFile = Join-Path $upperCaseFolder 'script.ps1' - $lowerCaseFile = Join-Path $lowerCaseFolder 'script.ps1' New-Item -ItemType Directory -Path $upperCaseFolder -Force | Out-Null Set-Content -Path $upperCaseFile -Value '# upper case folder' @@ -2759,7 +3282,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - It 'GetFilesToUpdate excludes any case unused file on case-insensitive platforms' -Skip:($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { + It 'GetFilesToUpdate excludes any case unused file on case-insensitive platforms' -Skip:$script:isLinuxPlatform { $upperCaseFile = Join-Path $templateFolder 'UnusedFile.ps1' $lowerCaseFile = Join-Path $templateFolder 'unusedfile.ps1' Set-Content -Path $upperCaseFile -Value '# upper case file' From 684ae30dbbf9992baa89f7b8ae272cbbd0b748c7 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Wed, 16 Sep 2026 19:40:46 +0200 Subject: [PATCH 21/34] Fix copilot review comments --- .../CheckForUpdates.HelperFunctions.ps1 | 88 ++++++++++++------- Scenarios/settings.md | 2 +- Tests/CheckForUpdates.Action.Test.ps1 | 40 ++++++++- 3 files changed, 97 insertions(+), 33 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index aa6fbe3100..2eb5f03327 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -813,49 +813,69 @@ function Test-PathPhysicallyContained { ) $pathComparison = GetPathStringComparison + + $Path = [System.IO.Path]::GetFullPath($Path) # canonicalize the path to an absolute path + $RootFolder = [System.IO.Path]::GetFullPath($RootFolder) # canonicalize the root folder to an absolute path $RootFolder = Join-Path $RootFolder '' # ensure the root folder path ends with a directory separator # Early exit if the path is obviously outside the root folder lexically if (-not $Path.StartsWith($RootFolder, $pathComparison)) { return $false } - - $realPath = $RootFolder $resolveReparsePoints = $true # once an ancestor doesn't exist, no deeper segment can be a reparse point either $hopLimit = 40 # matches the classic OS/.NET max-followed-symlinks limit (guards against cyclic chains) $hopCount = 0 - # Split the relative path into individual segments for iterative resolution - $segments = $Path.Substring($RootFolder.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) - foreach ($segment in $segments) { - $realPath = Join-Path $realPath $segment + # List of verified paths that have been confirmed to contain no unresolved reparse points. + $verifiedPaths = [System.Collections.Generic.List[string]]::new() + $verifiedPaths.Add($RootFolder) - # Follow a possible chain of reparse points (link -> link -> real) until fully resolved - while ($true) { - if (-not $resolveReparsePoints) { - break - } - if (-not (Test-Path -LiteralPath $realPath)) { - $resolveReparsePoints = $false - break - } + # Initialize the work queue of remaining path segments to walk. + $segments = [System.Collections.Generic.List[string]]::new() + $segments.AddRange([string[]] $Path.Substring($RootFolder.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) - $item = Get-Item -LiteralPath $realPath -Force - if ($item.LinkType -notin @('SymbolicLink', 'Junction') -or -not $item.Target) { - break - } - if ($hopCount++ -gt $hopLimit) { - # Cyclic or pathologically deep chain - fail closed, like the OS would refuse to resolve it too - OutputWarning "Path '$Path' could not be resolved: reparse point chain exceeded $($hopLimit) hops (cyclic or too deep) at '$realPath'. Treating as not contained." - return $false - } + $realPath = $RootFolder - $target = @($item.Target)[0] - if (-not [System.IO.Path]::IsPathRooted($target)) { - $target = Join-Path (Split-Path -Path $realPath -Parent) $target - } - $realPath = [System.IO.Path]::GetFullPath($target) + while ($segments.Count -gt 0) { + $realPath = Join-Path $realPath $segments[0] + $segments.RemoveAt(0) + + if (-not $resolveReparsePoints) { + continue + } + if (-not (Test-Path -LiteralPath $realPath)) { + $resolveReparsePoints = $false + continue } + + $item = Get-Item -LiteralPath $realPath -Force + if ($item.LinkType -notin @('SymbolicLink', 'Junction') -or -not $item.Target) { + $verifiedPaths.Add((Join-Path $realPath '')) # this segment itself is confirmed not a reparse point + continue + } + if ($hopCount++ -gt $hopLimit) { + # Cyclic or pathologically deep chain - fail closed, like the OS would refuse to resolve it too + OutputWarning "Path '$Path' could not be resolved: reparse point chain exceeded $($hopLimit) hops (cyclic or too deep) at '$realPath'. Treating as not contained." + return $false + } + + $target = @($item.Target)[0] + if (-not [System.IO.Path]::IsPathRooted($target)) { + $target = Join-Path (Split-Path -Path $realPath -Parent) $target + } + $target = [System.IO.Path]::GetFullPath($target) + + # Find the longest verified path that is a prefix of the target. This helps to minimize redundant checks for already verified path segments. + $verifiedPath = $verifiedPaths | Sort-Object -Descending | Where-Object { $target.StartsWith($_, $pathComparison) } | Select-Object -First 1 + if (-not $verifiedPath) { + # If no verified path matches the target, start verification from the root of the target path. + $verifiedPath = [System.IO.Path]::GetPathRoot($target) + } + + # Re-inject every unverified segment of the resolved target so an embedded reparse point (e.g. link1 -> + # "link2/sub" where link2 itself escapes the root) gets its own check on a later iteration. + $segments.InsertRange(0, [string[]] $target.Substring($verifiedPath.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) + $realPath = $verifiedPath } $realPath = [System.IO.Path]::GetFullPath($realPath) @@ -1013,9 +1033,15 @@ function ResolveFilePaths { $project = '' # If project is '.', it means the root folder, so we use an empty string } - $projectDestinationFolder = Join-Path $destinationFolder $project + $unresolvedProjectDestinationFolder = Join-Path $destinationFolder $project + $unresolvedProjectDestinationFolder = Join-Path $unresolvedProjectDestinationFolder '' # Ensure unresolved project destination folder has a trailing slash for correct path resolution + $projectDestinationFolder = [System.IO.Path]::GetFullPath($unresolvedProjectDestinationFolder) # Canonicalize the unresolved project destination folder to an absolute path $projectDestinationFolder = Join-Path $projectDestinationFolder '' # Ensure project destination folder has a trailing slash for correct path resolution - # Do not canonicalize the project destination folder to an absolute path, or "dest/foo/bar/../" would become the valid project folder "dest/foo/" and potentially allow files to escape the intended project folder. + + if ($unresolvedProjectDestinationFolder -ne $projectDestinationFolder) { + OutputWarning "Skipping file '$srcFile' for project '$project': project destination folder '$unresolvedProjectDestinationFolder' resolves to a different path '$projectDestinationFolder'." + continue + } $fileDestinationFolder = Join-Path $projectDestinationFolder $file.destinationFolder $fileDestinationFolder = [System.IO.Path]::GetFullPath($fileDestinationFolder) # Canonicalize the file destination folder to an absolute path diff --git a/Scenarios/settings.md b/Scenarios/settings.md index c0118f92d4..8598074a88 100644 --- a/Scenarios/settings.md +++ b/Scenarios/settings.md @@ -248,7 +248,7 @@ Please read the release notes carefully when installing new versions of AL-Go fo | BcContainerHelperVersion | This setting can be set to a specific version (ex. 3.0.8) of BcContainerHelper to force AL-Go to use this version. **latest** means that AL-Go will use the latest released version. **preview** means that AL-Go will use the latest preview version. **dev** means that AL-Go will use the dev branch of containerhelper. | latest (or preview for AL-Go preview) | | unusedALGoSystemFiles (**deprecated**) | An array of AL-Go System Files, which won't be updated during Update AL-Go System Files. They will instead be removed.
Use this setting with care, as this can break the AL-Go for GitHub functionality and potentially leave your repo no longer functional. | [ ] | | reportSuppressedDiagnostics | If this setting is set to true, the AL compiler will report diagnostics which are suppressed in the code using the pragma `#pragma warning disable `. This can be useful if you want to ensure that no warnings are suppressed in your code. | false | -| customALGoFiles | An object to configure custom AL-Go files, that will be updated during "Update AL-Go System Files" workflow. The object can contain properties `filesToInclude` and `filesToExclude`. Read more at [Customizing AL-Go](CustomizingALGoForGitHub.md#Using-custom-template-files). | `{ "filesToInclude": [], "filesToExclude": [] }` +| customALGoFiles | An object to configure custom AL-Go files, that will be updated during "Update AL-Go System Files" workflow. Read more at [Customizing AL-Go](CustomizingALGoForGitHub.md#Using-custom-template-files).
**filesToInclude** = an array of file specifications to include (create/update). Each item can contain **sourceFolder** (folder relative to the template root to look for files, default root folder), **filter** (filter string supporting `*` and `?` wildcards, default all files), **destinationFolder** (folder relative to the repository to place the files, default same as sourceFolder), **destinationName** (filename to use at the destination, overriding the source filename to rename the file when copied; should be used together with a filter matching a single file, default source filename), and **perProject** (boolean indicating whether the files should be propagated to all AL-Go projects, in which case destinationFolder is relative to the project folder, default false).
**filesToExclude** = an array of file specifications to exclude (remove) from `filesToInclude`. Each item can contain **sourceFolder** and **filter** (same meaning as above). | `{ "filesToInclude": [], "filesToExclude": [] }` ## Overwrite settings diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 8f0e03de1b..ee8c865ecb 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -1400,7 +1400,7 @@ Describe "ResolveFilePaths" { $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @($project)) $fullFilePaths | Should -BeNullOrEmpty - Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*for project '$project': destination folder*outside the * destination folder*" } + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*for project '$project': project destination folder * resolves to a different path *" } } It 'ResolveFilePaths with type' { @@ -2376,6 +2376,25 @@ Describe "Test-PathPhysicallyContained" { } } + It 'Test-PathPhysicallyContained returns false when a symlink target embeds another symlink that escapes the root as a non-final segment' -Skip:(-not $script:hasSymlinkCapability) { + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $externalSubFolder = Join-Path $externalFolder "sub" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -Path $externalSubFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath2 -Target $externalFolder -Force | Out-Null + # link1's target textually starts under the root, but embeds link2 (which escapes the root) as a non-final segment + New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target (Join-Path $internalLinkPath2 "sub") -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalSubFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + It 'Test-PathPhysicallyContained returns true through a symlink with a relative target that stays within the root' -Skip:(-not $script:hasSymlinkCapability) { $internalFolderPath = Join-Path $rootFolder "folder" $internalLinkPath = Join-Path $rootFolder "link" @@ -2507,6 +2526,25 @@ Describe "Test-PathPhysicallyContained" { } } + It 'Test-PathPhysicallyContained returns false when a junction target embeds another junction that escapes the root as a non-final segment' -Skip:(-not $script:isWindowsPlatform) { + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $externalSubFolder = Join-Path $externalFolder "sub" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -Path $externalSubFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath2 -Target $externalFolder -Force | Out-Null + # link1's target textually starts under the root, but embeds link2 (which escapes the root) as a non-final segment + New-Item -ItemType Junction -Path $internalLinkPath1 -Target (Join-Path $internalLinkPath2 "sub") -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalSubFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + It 'Test-PathPhysicallyContained returns false and completes without hanging for a cyclic junction pair' -Skip:(-not $script:isWindowsPlatform) { $internalLinkPath1 = Join-Path $rootFolder "link1" $internalLinkPath2 = Join-Path $rootFolder "link2" From 6b0714b3560c7251f5eb95254b3e768a6b79fa51 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Wed, 16 Sep 2026 20:30:42 +0200 Subject: [PATCH 22/34] Fix copilot review comment --- .../CheckForUpdates.HelperFunctions.ps1 | 7 ++ Tests/CheckForUpdates.Action.Test.ps1 | 93 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 2eb5f03327..b527efa807 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -1038,11 +1038,18 @@ function ResolveFilePaths { $projectDestinationFolder = [System.IO.Path]::GetFullPath($unresolvedProjectDestinationFolder) # Canonicalize the unresolved project destination folder to an absolute path $projectDestinationFolder = Join-Path $projectDestinationFolder '' # Ensure project destination folder has a trailing slash for correct path resolution + # Check if the unresolved project destination folder resolves to the same absolute path (e.g. catches ".." and "." segments) if ($unresolvedProjectDestinationFolder -ne $projectDestinationFolder) { OutputWarning "Skipping file '$srcFile' for project '$project': project destination folder '$unresolvedProjectDestinationFolder' resolves to a different path '$projectDestinationFolder'." continue } + # Check if the project destination folder is under the base destination folder (symlink/junction-aware) + if (-not (Test-PathPhysicallyContained -Path $projectDestinationFolder -RootFolder $destinationFolder)) { + OutputWarning "Skipping file '$srcFile' for project '$project': project destination folder '$projectDestinationFolder' is outside the base destination folder '$destinationFolder'." + continue + } + $fileDestinationFolder = Join-Path $projectDestinationFolder $file.destinationFolder $fileDestinationFolder = [System.IO.Path]::GetFullPath($fileDestinationFolder) # Canonicalize the file destination folder to an absolute path $fileDestinationFolder = Join-Path $fileDestinationFolder '' # Ensure file destination folder has a trailing slash for correct path resolution diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index ee8c865ecb..c30614a79f 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -1642,6 +1642,99 @@ Describe "ResolveFilePaths" { Should -Invoke OutputWarning -Times 1 } + It 'ResolveFilePaths skips per-project entries when the project folder is a junction pointing outside the destination folder' -Skip:(-not $script:isWindowsPlatform) { + $externalFolder = Join-Path $rootFolder 'external' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $projectLinkPath = Join-Path $destinationFolder 'externalProject' + + try { + # 'sub' must exist under the escape target so the per-project checks don't short-circuit on a not-yet-existing path + New-Item -Path (Join-Path $externalFolder 'sub') -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $projectLinkPath -Target $externalFolder -Force | Out-Null + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'sub'; 'perProject' = $true } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('externalProject')) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 + } + finally { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths skips per-project entries when the project folder is a symlink pointing outside the destination folder' -Skip:(-not $script:hasSymlinkCapability) { + $externalFolder = Join-Path $rootFolder 'external' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $projectLinkPath = Join-Path $destinationFolder 'externalProject' + + try { + New-Item -Path (Join-Path $externalFolder 'sub') -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $projectLinkPath -Target $externalFolder -Force | Out-Null + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'sub'; 'perProject' = $true } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('externalProject')) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 + } + finally { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths resolves per-project entries when the project folder is a junction that stays within the destination folder' -Skip:(-not $script:isWindowsPlatform) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $destinationSubfolder = Join-Path $destinationFolder 'subfolder' + $projectLinkPath = Join-Path $destinationFolder 'internalProject' + New-Item -Path (Join-Path $destinationSubfolder 'sub') -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $projectLinkPath -Target $destinationSubfolder -Force | Out-Null + + try { + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'sub'; 'perProject' = $true } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('internalProject')) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalProject/sub/File1.txt') + } + finally { + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths resolves per-project entries when the project folder is a symlink that stays within the destination folder' -Skip:(-not $script:hasSymlinkCapability) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $destinationSubfolder = Join-Path $destinationFolder 'subfolder' + $projectLinkPath = Join-Path $destinationFolder 'internalProject' + New-Item -Path (Join-Path $destinationSubfolder 'sub') -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $projectLinkPath -Target $destinationSubfolder -Force | Out-Null + + try { + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'sub'; 'perProject' = $true } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('internalProject')) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalProject/sub/File1.txt') + } + finally { + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + It 'ResolveFilePaths skips destinations reachable only through a junction pointing outside the destination folder' -Skip:(-not $script:isWindowsPlatform) { $externalFolder = Join-Path $rootFolder 'external' $destinationFolder = Join-Path $rootFolder 'destinationFolder' From 5d945c0ff7b29216718430e2d83fbb713bdc761b Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Wed, 23 Sep 2026 21:32:27 +0200 Subject: [PATCH 23/34] fix dangling links --- .../CheckForUpdates.HelperFunctions.ps1 | 5 +- Tests/CheckForUpdates.Action.Test.ps1 | 69 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index b527efa807..622d2e4c9d 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -843,12 +843,13 @@ function Test-PathPhysicallyContained { if (-not $resolveReparsePoints) { continue } - if (-not (Test-Path -LiteralPath $realPath)) { + + $item = Get-Item -LiteralPath $realPath -Force -ErrorAction SilentlyContinue + if (-not $item) { $resolveReparsePoints = $false continue } - $item = Get-Item -LiteralPath $realPath -Force if ($item.LinkType -notin @('SymbolicLink', 'Junction') -or -not $item.Target) { $verifiedPaths.Add((Join-Path $realPath '')) # this segment itself is confirmed not a reparse point continue diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 66249eb07c..adcb9b7b19 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -2594,6 +2594,40 @@ Describe "Test-PathPhysicallyContained" { } } + It 'Test-PathPhysicallyContained returns true through a single dangling symlink that stays within the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalDanglingFolderPath = Join-Path $rootFolder "dangling" + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + New-Item -ItemType Directory -Path $internalDanglingFolderPath -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $internalDanglingFolderPath -Force | Out-Null + Remove-Item -Path $internalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $internalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false through a single dangling symlink that points outside the root' -Skip:(-not $script:hasSymlinkCapability) { + $externalDanglingFolderPath = Join-Path $externalFolder "dangling" + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + New-Item -ItemType Directory -Path $externalDanglingFolderPath -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $externalDanglingFolderPath -Force | Out-Null + Remove-Item -Path $externalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + It 'Test-PathPhysicallyContained returns false and completes without hanging for a cyclic symlink pair' -Skip:(-not $script:hasSymlinkCapability) { $internalLinkPath1 = Join-Path $rootFolder "link1" $internalLinkPath2 = Join-Path $rootFolder "link2" @@ -2712,6 +2746,41 @@ Describe "Test-PathPhysicallyContained" { } } + + It 'Test-PathPhysicallyContained returns true through a single dangling junctions that stays within the root' -Skip:(-not $script:isWindowsPlatform) { + $internalDanglingFolderPath = Join-Path $rootFolder "dangling" + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + New-Item -ItemType Directory -Path $internalDanglingFolderPath -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath -Target $internalDanglingFolderPath -Force | Out-Null + Remove-Item -Path $internalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $internalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false through a single dangling junctions that points outside the root' -Skip:(-not $script:isWindowsPlatform) { + $externalDanglingFolderPath = Join-Path $externalFolder "dangling" + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + New-Item -ItemType Directory -Path $externalDanglingFolderPath -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath -Target $externalDanglingFolderPath -Force | Out-Null + Remove-Item -Path $externalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + It 'Test-PathPhysicallyContained returns false and completes without hanging for a cyclic junction pair' -Skip:(-not $script:isWindowsPlatform) { $internalLinkPath1 = Join-Path $rootFolder "link1" $internalLinkPath2 = Join-Path $rootFolder "link2" From b42c085c748aaca104dfe8505e237c6e84e3f2e1 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Wed, 23 Sep 2026 23:42:31 +0200 Subject: [PATCH 24/34] reworked symlink/junction handling --- .../CheckForUpdates.HelperFunctions.ps1 | 128 +++- Actions/CheckForUpdates/CheckForUpdates.ps1 | 35 +- Tests/CheckForUpdates.Action.Test.ps1 | 702 +++--------------- 3 files changed, 249 insertions(+), 616 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 622d2e4c9d..e076492cc5 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -792,19 +792,19 @@ function GetPathStringComparer { <# .SYNOPSIS -Checks whether a path is physically contained within a root folder, resolving symlinks/junctions. +Checks whether a path is lexically contained within a root folder, resolving ".." and "." segments. .DESCRIPTION -Verifies that $Path is located under $RootFolder both lexically and after resolving any -symbolic links or junctions along the way, protecting against paths that escape the root -folder via reparse points. Both parameters are treated as literal paths (no wildcard expansion). +Verifies that $Path is located under $RootFolder purely by string/segment resolution +(via [System.IO.Path]::GetFullPath()) - no filesystem access, so it does not detect escapes via +symlinks/junctions. Both parameters are treated as literal paths (no wildcard expansion). .PARAMETER Path The literal path to check. .PARAMETER RootFolder The literal root folder that $Path must be contained within. .OUTPUTS -$true if the path is physically contained within the root folder, otherwise $false. +$true if the path is lexically contained within the root folder, otherwise $false. #> -function Test-PathPhysicallyContained { +function Test-PathLexicallyContained { Param( [Parameter(Mandatory=$true)] [string] $Path, @@ -814,14 +814,48 @@ function Test-PathPhysicallyContained { $pathComparison = GetPathStringComparison - $Path = [System.IO.Path]::GetFullPath($Path) # canonicalize the path to an absolute path + $Path = [System.IO.Path]::GetFullPath($Path) # canonicalize the path to an absolute path (resolves ".."/".") $RootFolder = [System.IO.Path]::GetFullPath($RootFolder) # canonicalize the root folder to an absolute path $RootFolder = Join-Path $RootFolder '' # ensure the root folder path ends with a directory separator - # Early exit if the path is obviously outside the root folder lexically - if (-not $Path.StartsWith($RootFolder, $pathComparison)) { - return $false + return $Path.StartsWith($RootFolder, $pathComparison) +} + +<# +.SYNOPSIS +Resolves a path to its final physical location, following any symbolic links/junctions along the way. +.DESCRIPTION +Walks $Path segment by segment starting from $RootFolder (ancestors at or above $RootFolder are assumed to +already be free of reparse points and are not resolved), following any symbolic link or junction encountered +so the result reflects where the OS would actually read/write - not just the literal path segments. This +also catches a reparse point that redirects to another location INSIDE $RootFolder (e.g. ".github" being a +symlink to "ProjectA/.github"): the resolved path differs from the literal $Path even though it never +escapes $RootFolder, which plain containment checks would miss. Both parameters are treated as literal +paths (no wildcard expansion). +.PARAMETER Path +The literal path to resolve. Must be lexically contained within $RootFolder. +.PARAMETER RootFolder +The root folder above which no further reparse-point resolution is needed/performed. +.OUTPUTS +The fully resolved physical path, or $null if resolution failed (path not lexically contained within +$RootFolder, or a reparse point chain exceeded the hop limit). +#> +function Resolve-PhysicalPath { + Param( + [Parameter(Mandatory=$true)] + [string] $Path, + [Parameter(Mandatory=$true)] + [string] $RootFolder + ) + + if (-not (Test-PathLexicallyContained -Path $Path -RootFolder $RootFolder)) { + return $null } + + $Path = [System.IO.Path]::GetFullPath($Path) # canonicalize the path to an absolute path + $RootFolder = [System.IO.Path]::GetFullPath($RootFolder) # canonicalize the root folder to an absolute path + $RootFolder = Join-Path $RootFolder '' # ensure the root folder path ends with a directory separator + $resolveReparsePoints = $true # once an ancestor doesn't exist, no deeper segment can be a reparse point either $hopLimit = 40 # matches the classic OS/.NET max-followed-symlinks limit (guards against cyclic chains) $hopCount = 0 @@ -857,7 +891,7 @@ function Test-PathPhysicallyContained { if ($hopCount++ -gt $hopLimit) { # Cyclic or pathologically deep chain - fail closed, like the OS would refuse to resolve it too OutputWarning "Path '$Path' could not be resolved: reparse point chain exceeded $($hopLimit) hops (cyclic or too deep) at '$realPath'. Treating as not contained." - return $false + return $null } $target = @($item.Target)[0] @@ -867,7 +901,7 @@ function Test-PathPhysicallyContained { $target = [System.IO.Path]::GetFullPath($target) # Find the longest verified path that is a prefix of the target. This helps to minimize redundant checks for already verified path segments. - $verifiedPath = $verifiedPaths | Sort-Object -Descending | Where-Object { $target.StartsWith($_, $pathComparison) } | Select-Object -First 1 + $verifiedPath = $verifiedPaths | Sort-Object -Descending | Where-Object { Test-PathLexicallyContained -Path $target -RootFolder $_ } | Select-Object -First 1 if (-not $verifiedPath) { # If no verified path matches the target, start verification from the root of the target path. $verifiedPath = [System.IO.Path]::GetPathRoot($target) @@ -879,9 +913,40 @@ function Test-PathPhysicallyContained { $realPath = $verifiedPath } - $realPath = [System.IO.Path]::GetFullPath($realPath) + return $realPath +} + +<# +.SYNOPSIS +Checks whether a path physically resolves to itself, i.e. no reparse point along the way redirects it. +.DESCRIPTION +Resolves $Path (see Resolve-PhysicalPath) and compares the result to $Path itself. This requires the +resolved path to be the EXACT path given - catching a reparse point that redirects to a different, +still-in-bounds location (e.g. an ancestor directory symlinked to another folder within the same root), +which plain lexical/physical containment checks would not detect. Use this to guard a destination that +must be written to/removed at the exact intended path. +.PARAMETER Path +The literal path expected to be its own final physical location. +.PARAMETER RootFolder +The root folder above which no further reparse-point resolution is needed/performed. +.OUTPUTS +$true if $Path resolves to itself, otherwise $false. +#> +function Test-PathPhysicallyEqual { + Param( + [Parameter(Mandatory=$true)] + [string] $Path, + [Parameter(Mandatory=$true)] + [string] $RootFolder + ) + + $realPath = Resolve-PhysicalPath -Path $Path -RootFolder $RootFolder + if (-not $realPath) { + return $false + } - return $realPath.StartsWith($RootFolder, $pathComparison) + $pathComparer = GetPathStringComparer + return $pathComparer.Equals($realPath, [System.IO.Path]::GetFullPath($Path)) } <# @@ -892,7 +957,7 @@ Resolves file paths based on the provided source folder, destination folder, and This function takes a source folder, an optional original source folder, a destination folder, and an array of file specifications. It resolves the full paths for each specified file, considering their origin (template or custom template), type, and whether they are per-project files. The function returns an array of hashtables containing the resolved source and destination file paths. The function is used to determine which files need to be copied from the template repository to the target repository during the AL-Go update process. -Destination boundary checks are symlink/junction-aware (see Test-PathPhysicallyContained): a destination path is only accepted if it is both lexically and physically (after resolving reparse points) contained within the destination folder. +Both source and destination boundary checks are lexical-only (see Test-PathLexicallyContained, resolving ".."/"." segments) - neither does a filesystem-aware symlink/junction walk here. Physical resolution (see Test-PathPhysicallyEqual/Resolve-PhysicalPath) is instead re-checked immediately before each source file is actually read and each destination file is actually written/removed, in CheckForUpdates.ps1: for the destination this is required, since the destination folder is later relocated into a new clone (see CloneIntoNewFolder) before files are written, so a filesystem-based check here would validate the wrong location anyway; for the source there is no such relocation, but checking at the point of use keeps both sides symmetric and covers the file exactly as it will be read. sourceFolder: The base folder of the template used to resolve the source file paths. originalSourceFolder: The base folder of the original template used to check for original files (can be $null). This is in the case of custom templates, where if the file exists in the original template, it should be used instead of the custom template file. destinationFolder: The base folder used to construct the destination file paths. This is typically the root folder of the target repository. @@ -1001,8 +1066,8 @@ function ResolveFilePaths { 'destinationFullPath' = $null } - # Check if the source file is under the source folder (symlink/junction-aware) - if (-not (Test-PathPhysicallyContained -Path $srcFile -RootFolder $sourceFolder)) { + # Check if the source file is under the source folder (lexical, resolves ".."/".") + if (-not (Test-PathLexicallyContained -Path $srcFile -RootFolder $sourceFolder)) { OutputDebug "Skipping source file '$($srcFile)' as it is not under the source folder '$($sourceFolder)'." continue } @@ -1015,8 +1080,13 @@ function ResolveFilePaths { $originalSourceFile = Join-Path $originalSourceFolder $relativeSourceFile $originalSourceFile = [System.IO.Path]::GetFullPath($originalSourceFile) if (Test-Path -LiteralPath $originalSourceFile -PathType Leaf) { - # If the file exists in the original template folder, use that file instead - $fullFilePath.originalSourceFullPath = $originalSourceFile + if (Test-PathLexicallyContained -Path $originalSourceFile -RootFolder $originalSourceFolder) { + # If the file exists in the original template folder, use that file instead + $fullFilePath.originalSourceFullPath = $originalSourceFile + } + else { + OutputWarning "Skipping original source file '$originalSourceFile' for source file '$srcFile' as it is not under the original source folder '$originalSourceFolder'." + } } } @@ -1045,8 +1115,8 @@ function ResolveFilePaths { continue } - # Check if the project destination folder is under the base destination folder (symlink/junction-aware) - if (-not (Test-PathPhysicallyContained -Path $projectDestinationFolder -RootFolder $destinationFolder)) { + # Check if the project destination folder is under the base destination folder (lexical, resolves ".."/".") + if (-not (Test-PathLexicallyContained -Path $projectDestinationFolder -RootFolder $destinationFolder)) { OutputWarning "Skipping file '$srcFile' for project '$project': project destination folder '$projectDestinationFolder' is outside the base destination folder '$destinationFolder'." continue } @@ -1055,8 +1125,8 @@ function ResolveFilePaths { $fileDestinationFolder = [System.IO.Path]::GetFullPath($fileDestinationFolder) # Canonicalize the file destination folder to an absolute path $fileDestinationFolder = Join-Path $fileDestinationFolder '' # Ensure file destination folder has a trailing slash for correct path resolution - # Check if the destination folder is under the project destination folder (symlink/junction-aware) - if (-not (Test-PathPhysicallyContained -Path $fileDestinationFolder -RootFolder $projectDestinationFolder)) { + # Check if the destination folder is under the project destination folder (lexical, resolves ".."/".") + if (-not (Test-PathLexicallyContained -Path $fileDestinationFolder -RootFolder $projectDestinationFolder)) { OutputWarning "Skipping file '$srcFile' for project '$project': destination folder '$fileDestinationFolder' is outside the project destination folder '$projectDestinationFolder'." continue } @@ -1065,8 +1135,8 @@ function ResolveFilePaths { $fullProjectFilePath.destinationFullPath = Join-Path $fileDestinationFolder $destinationName $fullProjectFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullProjectFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path - # Check if the destination file is under the file destination folder (symlink/junction-aware) - if (-not (Test-PathPhysicallyContained -Path $fullProjectFilePath.destinationFullPath -RootFolder $fileDestinationFolder)) { + # Check if the destination file is under the file destination folder (lexical, resolves ".."/".") + if (-not (Test-PathLexicallyContained -Path $fullProjectFilePath.destinationFullPath -RootFolder $fileDestinationFolder)) { OutputWarning "Skipping file '$srcFile' for project '$project': destination file '$($fullProjectFilePath.destinationFullPath)' is outside the file destination folder '$fileDestinationFolder'." continue } @@ -1087,8 +1157,8 @@ function ResolveFilePaths { $fileDestinationFolder = [System.IO.Path]::GetFullPath($fileDestinationFolder) # Canonicalize the file destination folder to an absolute path $fileDestinationFolder = Join-Path $fileDestinationFolder '' # Ensure file destination folder has a trailing slash for correct path resolution - # Check if the destination folder is under the base destination folder (symlink/junction-aware) - if (-not (Test-PathPhysicallyContained -Path $fileDestinationFolder -RootFolder $destinationFolder)) { + # Check if the destination folder is under the base destination folder (lexical, resolves ".."/".") + if (-not (Test-PathLexicallyContained -Path $fileDestinationFolder -RootFolder $destinationFolder)) { OutputWarning "Skipping file '$srcFile': destination folder '$fileDestinationFolder' is outside the base destination folder '$destinationFolder'." continue } @@ -1096,8 +1166,8 @@ function ResolveFilePaths { $fullFilePath.destinationFullPath = Join-Path $fileDestinationFolder $destinationName $fullFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path - # Check if the destination file is under the file destination folder (symlink/junction-aware) - if (-not (Test-PathPhysicallyContained -Path $fullFilePath.destinationFullPath -RootFolder $fileDestinationFolder)) { + # Check if the destination file is under the file destination folder (lexical, resolves ".."/".") + if (-not (Test-PathLexicallyContained -Path $fullFilePath.destinationFullPath -RootFolder $fileDestinationFolder)) { OutputWarning "Skipping file '$srcFile': destination file '$($fullFilePath.destinationFullPath)' is outside the file destination folder '$fileDestinationFolder'." continue } diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index 3025bf2f6b..f684661da0 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -148,6 +148,25 @@ foreach($fileToInclude in $filesToInclude) { $originalSrcPath = $srcPath } + # Skip files that do not physically resolve to themselves within the template or original template folders + if (Test-PathLexicallyContained -Path $srcPath -RootFolder $templateFolder -and -not (Test-PathPhysicallyEqual -Path $srcPath -RootFolder $templateFolder)) { + OutputWarning "Skipping file '$srcPath': source does not physically resolve to itself within the template folder. This may indicate a symlink/junction redirect." + continue + } elseif (Test-PathLexicallyContained -Path $srcPath -RootFolder $originalTemplateFolder -and -not (Test-PathPhysicallyEqual -Path $srcPath -RootFolder $originalTemplateFolder)) { + OutputWarning "Skipping file '$srcPath': source does not physically resolve to itself within the original template folder. This may indicate a symlink/junction redirect." + continue + } + if ($originalSrcPath -ne $srcPath) { + # Skip files with original files that do not physically resolve to themselves within the template or original template folders + if (Test-PathLexicallyContained -Path $originalSrcPath -RootFolder $templateFolder -and -not (Test-PathPhysicallyEqual -Path $originalSrcPath -RootFolder $templateFolder)) { + OutputWarning "Skipping file '$srcPath': original source '$originalSrcPath' does not physically resolve to itself within the template folder. This may indicate a symlink/junction redirect." + continue + } elseif (Test-PathLexicallyContained -Path $originalSrcPath -RootFolder $originalTemplateFolder -and -not (Test-PathPhysicallyEqual -Path $originalSrcPath -RootFolder $originalTemplateFolder)) { + OutputWarning "Skipping file '$srcPath': original source '$originalSrcPath' does not physically resolve to itself within the original template folder. This may indicate a symlink/junction redirect." + continue + } + } + $dstPath = $fileToInclude.destinationFullPath $dstFileExists = Test-Path -Path $dstPath -PathType Leaf @@ -257,10 +276,18 @@ else { invoke-git status + $dstRoot = (Get-Location).Path + # Update the files # Calculate the release notes, while updating $releaseNotes = "" $updateFiles | ForEach-Object { + # Skip files that do not physically resolve to themselves within the destination root folder + if (-not (Test-PathPhysicallyEqual -Path $_.DstFile -RootFolder $dstRoot)) { + OutputWarning "Skipping update of '$($_.DstFile)': destination does not physically resolve to itself. This may indicate a symlink/junction redirect." + return + } + # Create the destination folder if it doesn't exist $path = [System.IO.Path]::GetDirectoryName($_.DstFile) if ($path -and -not (Test-Path -path $path -PathType Container)) { @@ -288,8 +315,14 @@ else { $releaseNotes = "No release notes available!" } $removeFiles | ForEach-Object { + # Skip files that do not physically resolve to themselves within the destination root folder + if (-not (Test-PathPhysicallyEqual -Path $_ -RootFolder $dstRoot)) { + OutputWarning "Skipping removal of '$_': destination does not physically resolve to itself. This may indicate a symlink/junction redirect." + return + } + Write-Host "Remove $_" - Remove-Item (Join-Path (Get-Location).Path $_) -Force + Remove-Item -LiteralPath $_ -Force } # Update the templateUrl and templateSha in the repo settings file diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index adcb9b7b19..4e5d43dba7 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -1716,203 +1716,11 @@ Describe "ResolveFilePaths" { Should -Invoke OutputWarning -Times 1 } - It 'ResolveFilePaths skips per-project entries when the project folder is a junction pointing outside the destination folder' -Skip:(-not $script:isWindowsPlatform) { - $externalFolder = Join-Path $rootFolder 'external' - $destinationFolder = Join-Path $rootFolder 'destinationFolder' - $projectLinkPath = Join-Path $destinationFolder 'externalProject' - - try { - # 'sub' must exist under the escape target so the per-project checks don't short-circuit on a not-yet-existing path - New-Item -Path (Join-Path $externalFolder 'sub') -ItemType Directory -Force | Out-Null - New-Item -ItemType Junction -Path $projectLinkPath -Target $externalFolder -Force | Out-Null - $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'sub'; 'perProject' = $true } - ) - Mock OutputWarning {} - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('externalProject')) - - $fullFilePaths | Should -BeNullOrEmpty - Should -Invoke OutputWarning -Times 1 - } - finally { - Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue - } - } - - It 'ResolveFilePaths skips per-project entries when the project folder is a symlink pointing outside the destination folder' -Skip:(-not $script:hasSymlinkCapability) { - $externalFolder = Join-Path $rootFolder 'external' - $destinationFolder = Join-Path $rootFolder 'destinationFolder' - $projectLinkPath = Join-Path $destinationFolder 'externalProject' - - try { - New-Item -Path (Join-Path $externalFolder 'sub') -ItemType Directory -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $projectLinkPath -Target $externalFolder -Force | Out-Null - $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'sub'; 'perProject' = $true } - ) - Mock OutputWarning {} - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('externalProject')) - - $fullFilePaths | Should -BeNullOrEmpty - Should -Invoke OutputWarning -Times 1 - } - finally { - Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue - } - } - - It 'ResolveFilePaths resolves per-project entries when the project folder is a junction that stays within the destination folder' -Skip:(-not $script:isWindowsPlatform) { - $destinationFolder = Join-Path $rootFolder 'destinationFolder' - $destinationSubfolder = Join-Path $destinationFolder 'subfolder' - $projectLinkPath = Join-Path $destinationFolder 'internalProject' - New-Item -Path (Join-Path $destinationSubfolder 'sub') -ItemType Directory -Force | Out-Null - New-Item -ItemType Junction -Path $projectLinkPath -Target $destinationSubfolder -Force | Out-Null - - try { - $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'sub'; 'perProject' = $true } - ) - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('internalProject')) - - $fullFilePaths.Count | Should -Be 1 - $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalProject/sub/File1.txt') - } - finally { - Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue - } - } - - It 'ResolveFilePaths resolves per-project entries when the project folder is a symlink that stays within the destination folder' -Skip:(-not $script:hasSymlinkCapability) { - $destinationFolder = Join-Path $rootFolder 'destinationFolder' - $destinationSubfolder = Join-Path $destinationFolder 'subfolder' - $projectLinkPath = Join-Path $destinationFolder 'internalProject' - New-Item -Path (Join-Path $destinationSubfolder 'sub') -ItemType Directory -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $projectLinkPath -Target $destinationSubfolder -Force | Out-Null - - try { - $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'sub'; 'perProject' = $true } - ) - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('internalProject')) - - $fullFilePaths.Count | Should -Be 1 - $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalProject/sub/File1.txt') - } - finally { - Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue - } - } - - It 'ResolveFilePaths skips destinations reachable only through a junction pointing outside the destination folder' -Skip:(-not $script:isWindowsPlatform) { - $externalFolder = Join-Path $rootFolder 'external' - $destinationFolder = Join-Path $rootFolder 'destinationFolder' - $destinationLinkPath = Join-Path $destinationFolder 'externalLink' - - try { - New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null - New-Item -ItemType Junction -Path $destinationLinkPath -Target $externalFolder -Force | Out-Null - $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'externalLink' } - ) - Mock OutputWarning {} - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) - - $fullFilePaths | Should -BeNullOrEmpty - Should -Invoke OutputWarning -Times 1 - } - finally { - Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue - } - } - - It 'ResolveFilePaths skips destinations reachable only through a symlink pointing outside the destination folder' -Skip:(-not $script:hasSymlinkCapability) { - $externalFolder = Join-Path $rootFolder 'external' - $destinationFolder = Join-Path $rootFolder 'destinationFolder' - $destinationLinkPath = Join-Path $destinationFolder 'externalLink' - - try { - New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $destinationLinkPath -Target $externalFolder -Force | Out-Null - $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'externalLink' } - ) - Mock OutputWarning {} - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) - - $fullFilePaths | Should -BeNullOrEmpty - Should -Invoke OutputWarning -Times 1 - } - finally { - Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue - } - } - - It 'ResolveFilePaths resolves destinations reachable through a junction that stays within the destination folder' -Skip:(-not $script:isWindowsPlatform) { - $destinationFolder = Join-Path $rootFolder 'destinationFolder' - $destinationSubfolder = Join-Path $destinationFolder 'subfolder' - $destinationLinkPath = Join-Path $destinationFolder 'internalLink' - New-Item -Path $destinationSubfolder -ItemType Directory -Force | Out-Null - New-Item -ItemType Junction -Path $destinationLinkPath -Target $destinationSubfolder -Force | Out-Null - - try { - $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'internalLink' } - ) - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) - - $fullFilePaths.Count | Should -Be 1 - $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalLink/File1.txt') - } - finally { - Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue - } - } - - It 'ResolveFilePaths resolves destinations reachable through a symlink that stays within the destination folder' -Skip:(-not $script:hasSymlinkCapability) { - $destinationFolder = Join-Path $rootFolder 'destinationFolder' - $destinationSubfolder = Join-Path $destinationFolder 'subfolder' - $destinationLinkPath = Join-Path $destinationFolder 'internalLink' - New-Item -Path $destinationSubfolder -ItemType Directory -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $destinationLinkPath -Target $destinationSubfolder -Force | Out-Null - + It 'ResolveFilePaths skips source files reachable only via ".." traversal outside the source folder' { + $destinationFolder = Join-Path $rootFolder 'destinationFolderEscapeTest' try { $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'internalLink' } - ) - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) - - $fullFilePaths.Count | Should -Be 1 - $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalLink/File1.txt') - } - finally { - Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue - } - } - - It 'ResolveFilePaths skips source files reachable only through a junction pointing outside the source folder' -Skip:(-not $script:isWindowsPlatform) { - $externalFolder = Join-Path $rootFolder 'external' - $sourceFile = Join-Path $externalFolder 'File.txt' - $sourceLinkPath = Join-Path $sourceFolder 'externalLink' - $destinationFolder = Join-Path $rootFolder 'destinationFolder' - - try { - New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null - New-Item -Path $sourceFile -ItemType File -Force | Out-Null - New-Item -ItemType Junction -Path $sourceLinkPath -Target $externalFolder -Force | Out-Null - $files = @( - @{ 'sourceFolder' = 'externalLink'; 'filter' = '*.txt' } + @{ 'sourceFolder' = '../originalSourceFolder/folder'; 'filter' = '*.txt' } ) $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) @@ -1920,89 +1728,6 @@ Describe "ResolveFilePaths" { $fullFilePaths | Should -BeNullOrEmpty } finally { - Remove-Item -Path $sourceLinkPath -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue - } - } - - It 'ResolveFilePaths skips source files reachable only through a symlink pointing outside the source folder' -Skip:(-not $script:hasSymlinkCapability) { - $externalFolder = Join-Path $rootFolder 'external' - $sourceFile = Join-Path $externalFolder 'File.txt' - $sourceLinkPath = Join-Path $sourceFolder 'externalLink' - $destinationFolder = Join-Path $rootFolder 'destinationFolder' - - try { - New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null - New-Item -Path $sourceFile -ItemType File -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $sourceLinkPath -Target $externalFolder -Force | Out-Null - $files = @( - @{ 'sourceFolder' = 'externalLink'; 'filter' = '*.txt' } - ) - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) - - $fullFilePaths | Should -BeNullOrEmpty - } - finally { - Remove-Item -Path $sourceLinkPath -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue - } - } - - It 'ResolveFilePaths resolves source files reachable through a junction that stays within the source folder' -Skip:(-not $script:isWindowsPlatform) { - $sourceSubfolder = Join-Path $sourceFolder 'subfolder' - $sourceFile = Join-Path $sourceSubfolder 'File.txt' - $sourceLinkPath = Join-Path $sourceFolder 'internalLink' - $destinationFolder = Join-Path $rootFolder 'destinationFolder' - - try { - New-Item -Path $sourceSubfolder -ItemType Directory -Force | Out-Null - New-Item -Path $sourceFile -ItemType File -Force | Out-Null - New-Item -ItemType Junction -Path $sourceLinkPath -Target $sourceSubfolder -Force | Out-Null - - $files = @( - @{ 'sourceFolder' = 'internalLink'; 'filter' = '*.txt' } - ) - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) - - $fullFilePaths.Count | Should -Be 1 - $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder 'internalLink/File.txt') - $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalLink/File.txt') - } - finally { - Remove-Item -Path $sourceLinkPath -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $sourceSubfolder -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue - } - } - - It 'ResolveFilePaths resolves source files reachable through a symlink that stays within the source folder' -Skip:(-not $script:hasSymlinkCapability) { - $sourceSubfolder = Join-Path $sourceFolder 'subfolder' - $sourceFile = Join-Path $sourceSubfolder 'File.txt' - $sourceLinkPath = Join-Path $sourceFolder 'internalLink' - $destinationFolder = Join-Path $rootFolder 'destinationFolder' - - try { - New-Item -Path $sourceSubfolder -ItemType Directory -Force | Out-Null - New-Item -Path $sourceFile -ItemType File -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $sourceLinkPath -Target $sourceSubfolder -Force | Out-Null - - $files = @( - @{ 'sourceFolder' = 'internalLink'; 'filter' = '*.txt' } - ) - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) - - $fullFilePaths.Count | Should -Be 1 - $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder 'internalLink/File.txt') - $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalLink/File.txt') - } - finally { - Remove-Item -Path $sourceLinkPath -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $sourceSubfolder -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue } } @@ -2419,415 +2144,220 @@ Describe "ResolveFilePaths" { } } -Describe "Test-PathPhysicallyContained" { +Describe "Test-PathLexicallyContained" { BeforeAll { $actionName = "CheckForUpdates" $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") - $rootFolder = Join-Path $PSScriptRoot "physicallyContainedTests" - $externalFolder = Join-Path $PSScriptRoot "physicallyContainedTestsExternal" + $rootFolder = Join-Path $PSScriptRoot "lexicallyContainedTests" + $externalFolder = Join-Path $PSScriptRoot "lexicallyContainedTestsExternal" New-Item -Path $rootFolder -ItemType Directory -Force | Out-Null New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null } AfterAll { - if (Test-Path $rootFolder) { - Remove-Item -Path $rootFolder -Recurse -Force -ErrorAction SilentlyContinue - } - if (Test-Path $externalFolder) { - Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue - } + Remove-Item -Path $rootFolder, $externalFolder -Recurse -Force -ErrorAction SilentlyContinue } - It 'Test-PathPhysicallyContained returns true for a path lexically inside the root folder (no I/O involved)' { + It 'Test-PathLexicallyContained returns true for a path lexically inside the root folder (no I/O involved)' { $path = Join-Path $rootFolder "folder/file.txt" - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true } - It 'Test-PathPhysicallyContained returns false for a path lexically outside the root folder (no I/O involved)' { + It 'Test-PathLexicallyContained returns false for a path lexically outside the root folder (no I/O involved)' { $path = Join-Path $externalFolder "file.txt" - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false } - It 'Test-PathPhysicallyContained returns true when trailing path segments do not exist yet' { - $internalFolderPath = Join-Path $rootFolder "folder" - $path = Join-Path $internalFolderPath "subfolder/file.txt" - try { - New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null - - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true - } - finally { - Remove-Item -Path $internalFolderPath -Recurse -Force -ErrorAction SilentlyContinue - } + It 'Test-PathLexicallyContained returns true for a path that stays inside the root folder after resolving ".." segments' { + $path = Join-Path $rootFolder "folder/../folder2/file.txt" + Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true } - # Symbolic link tests (requires symlink capability) + It 'Test-PathLexicallyContained returns false for a path that escapes the root folder via ".." segments' { + $path = Join-Path $rootFolder "folder/../../outside.txt" + Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } - It 'Test-PathPhysicallyContained returns true through a single symlink that stays within the root' -Skip:(-not $script:hasSymlinkCapability) { - $internalFolderPath = Join-Path $rootFolder "folder" - $internalLinkPath = Join-Path $rootFolder "link" - $path = Join-Path $internalLinkPath "file.txt" - try { - New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $internalFolderPath -Force | Out-Null + It 'Test-PathLexicallyContained returns true for a path with "." segments that stays inside the root folder' { + $path = Join-Path $rootFolder "./folder/./file.txt" + Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true - } - finally { - Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $internalFolderPath -Recurse -Force -ErrorAction SilentlyContinue - } + It 'Test-PathLexicallyContained does not require the path to exist' { + $path = Join-Path $rootFolder "doesNotExist/file.txt" + Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true } - It 'Test-PathPhysicallyContained returns false through a single symlink that points outside the root' -Skip:(-not $script:hasSymlinkCapability) { + It 'Test-PathLexicallyContained does not perform filesystem/symlink resolution, unlike Test-PathPhysicallyEqual' -Skip:(-not $script:hasSymlinkCapability) { + $internalFolderPath = Join-Path $rootFolder "folder" $internalLinkPath = Join-Path $rootFolder "link" $path = Join-Path $internalLinkPath "file.txt" try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $externalFolder -Force | Out-Null - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + # Lexically the path looks contained (the link name itself is under $rootFolder); the lexical + # check must not follow the link to see that its target escapes - that's what Test-PathPhysicallyEqual is for. + Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + Test-PathPhysicallyEqual -Path $path -RootFolder $rootFolder | Should -Be $false } finally { Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $internalFolderPath -Recurse -Force -ErrorAction SilentlyContinue } } - It 'Test-PathPhysicallyContained returns true through a chain of two symlinks landing inside the root' -Skip:(-not $script:hasSymlinkCapability) { - $internalFolderPath = Join-Path $rootFolder "folder" - $internalLinkPath1 = Join-Path $rootFolder "link1" - $internalLinkPath2 = Join-Path $rootFolder "link2" - $path = Join-Path $internalLinkPath1 "file.txt" - try { - New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $internalLinkPath2 -Target $internalFolderPath -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null - - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true - } - finally { - Remove-Item -Path $internalFolderPath, $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue - } + It 'Test-PathLexicallyContained is case-insensitive on Windows' -Skip:(-not $script:isWindowsPlatform) { + $path = Join-Path $rootFolder "FOLDER/file.txt" + Test-PathLexicallyContained -Path $path -RootFolder (Join-Path $rootFolder "folder") | Should -Be $true } - It 'Test-PathPhysicallyContained returns true through a chain of two symlinks going outside but landing back inside the root' -Skip:(-not $script:hasSymlinkCapability) { - $internalFolderPath = Join-Path $rootFolder "folder" - $internalLinkPath1 = Join-Path $rootFolder "link1" - $externalLinkPath2 = Join-Path $externalFolder "link2" - $path = Join-Path $internalLinkPath1 "file.txt" - try { - New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $externalLinkPath2 -Target $internalFolderPath -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target $externalLinkPath2 -Force | Out-Null - - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true - } - finally { - Remove-Item -Path $internalFolderPath, $internalLinkPath1, $externalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue - } + It 'Test-PathLexicallyContained is case-sensitive on Linux' -Skip:(-not $script:isLinuxPlatform) { + $path = Join-Path $rootFolder "FOLDER/file.txt" + Test-PathLexicallyContained -Path $path -RootFolder (Join-Path $rootFolder "folder") | Should -Be $false } +} - It 'Test-PathPhysicallyContained returns false through a chain of two symlinks landing outside the root' -Skip:(-not $script:hasSymlinkCapability) { - $internalLinkPath1 = Join-Path $rootFolder "link1" - $internalLinkPath2 = Join-Path $rootFolder "link2" - $path = Join-Path $internalLinkPath1 "file.txt" - try { - New-Item -ItemType SymbolicLink -Path $internalLinkPath2 -Target $externalFolder -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null +Describe "Resolve-PhysicalPath" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false - } - finally { - Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue - } + $rootFolder = Join-Path $PSScriptRoot "resolvePhysicalPathTests" + $externalFolder = Join-Path $PSScriptRoot "resolvePhysicalPathTestsExternal" + New-Item -Path $rootFolder -ItemType Directory -Force | Out-Null + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null } - It 'Test-PathPhysicallyContained returns false when a symlink target embeds another symlink that escapes the root as a non-final segment' -Skip:(-not $script:hasSymlinkCapability) { - $internalLinkPath1 = Join-Path $rootFolder "link1" - $internalLinkPath2 = Join-Path $rootFolder "link2" - $externalSubFolder = Join-Path $externalFolder "sub" - $path = Join-Path $internalLinkPath1 "file.txt" - try { - New-Item -Path $externalSubFolder -ItemType Directory -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $internalLinkPath2 -Target $externalFolder -Force | Out-Null - # link1's target textually starts under the root, but embeds link2 (which escapes the root) as a non-final segment - New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target (Join-Path $internalLinkPath2 "sub") -Force | Out-Null - - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false - } - finally { - Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $externalSubFolder -Recurse -Force -ErrorAction SilentlyContinue - } + AfterAll { + Remove-Item -Path $rootFolder, $externalFolder -Recurse -Force -ErrorAction SilentlyContinue } - It 'Test-PathPhysicallyContained returns true through a symlink with a relative target that stays within the root' -Skip:(-not $script:hasSymlinkCapability) { - $internalFolderPath = Join-Path $rootFolder "folder" - $internalLinkPath = Join-Path $rootFolder "link" - $path = Join-Path $internalLinkPath "file.txt" - try { - Push-Location $rootFolder - New-Item -ItemType Directory -Path $internalFolderPath -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target "folder" -Force | Out-Null - - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true - } - finally { - Remove-Item -Path $internalFolderPath, $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue - Pop-Location - } + It 'Resolve-PhysicalPath returns the canonicalized path when there are no reparse points' { + $path = Join-Path $rootFolder "folder/file.txt" + Resolve-PhysicalPath -Path $path -RootFolder $rootFolder | Should -Be ([System.IO.Path]::GetFullPath($path)) } - It 'Test-PathPhysicallyContained returns false through a symlink with a relative target landing outside the root' -Skip:(-not $script:hasSymlinkCapability) { - $internalLinkPath = Join-Path $rootFolder "link" - $path = Join-Path $internalLinkPath "file.txt" - try { - Push-Location $rootFolder - New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target "../physicallyContainedTestsExternal" -Force | Out-Null - - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false - } - finally { - Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue - Pop-Location - } + It 'Resolve-PhysicalPath returns $null for a path lexically outside the root folder (no I/O involved)' { + $path = Join-Path $externalFolder "file.txt" + Resolve-PhysicalPath -Path $path -RootFolder $rootFolder | Should -Be $null } - It 'Test-PathPhysicallyContained returns true through a single dangling symlink that stays within the root' -Skip:(-not $script:hasSymlinkCapability) { - $internalDanglingFolderPath = Join-Path $rootFolder "dangling" + It 'Resolve-PhysicalPath returns $null through a symlink that points outside the root' -Skip:(-not $script:hasSymlinkCapability) { $internalLinkPath = Join-Path $rootFolder "link" $path = Join-Path $internalLinkPath "file.txt" try { - New-Item -ItemType Directory -Path $internalDanglingFolderPath -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $internalDanglingFolderPath -Force | Out-Null - Remove-Item -Path $internalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $externalFolder -Force | Out-Null - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + Resolve-PhysicalPath -Path $path -RootFolder $rootFolder | Should -Be $null } finally { Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $internalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue } } - It 'Test-PathPhysicallyContained returns false through a single dangling symlink that points outside the root' -Skip:(-not $script:hasSymlinkCapability) { - $externalDanglingFolderPath = Join-Path $externalFolder "dangling" + It 'Resolve-PhysicalPath resolves to the real target path through a symlink that stays within the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalFolderPath = Join-Path $rootFolder "folder" $internalLinkPath = Join-Path $rootFolder "link" $path = Join-Path $internalLinkPath "file.txt" try { - New-Item -ItemType Directory -Path $externalDanglingFolderPath -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $externalDanglingFolderPath -Force | Out-Null - Remove-Item -Path $externalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue + New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $internalFolderPath -Force | Out-Null - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + Resolve-PhysicalPath -Path $path -RootFolder $rootFolder | Should -Be (Join-Path $internalFolderPath "file.txt") } finally { Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $externalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue - } - } - - It 'Test-PathPhysicallyContained returns false and completes without hanging for a cyclic symlink pair' -Skip:(-not $script:hasSymlinkCapability) { - $internalLinkPath1 = Join-Path $rootFolder "link1" - $internalLinkPath2 = Join-Path $rootFolder "link2" - $path = Join-Path $internalLinkPath1 "file.txt" - try { - New-Item -ItemType Directory -Path $internalLinkPath1 -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $internalLinkPath2 -Target $internalLinkPath1 -Force | Out-Null - Remove-Item -Path $internalLinkPath1 -Recurse -Force -ErrorAction SilentlyContinue - New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null - Mock OutputWarning {} - - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false - - Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "Path '$path' could not be resolved: reparse point chain exceeded * hops (cyclic or too deep) at '*'. Treating as not contained." } - } - finally { - Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $internalFolderPath -Recurse -Force -ErrorAction SilentlyContinue } } - # Junction tests (Windows only) - - It 'Test-PathPhysicallyContained returns true through a single junction that stays within the root' -Skip:(-not $script:isWindowsPlatform) { - $internalFolderPath = Join-Path $rootFolder "folder" - $internalLinkPath = Join-Path $rootFolder "link" - $path = Join-Path $internalLinkPath "file.txt" + It 'Resolve-PhysicalPath reveals an in-root redirect that lexical containment alone would miss' -Skip:(-not $script:hasSymlinkCapability) { + # A symlink that redirects to a DIFFERENT location still inside the root: containment alone says "fine", + # but the resolved path is not the one the caller intended to read/write - callers that need the write to + # land at an EXACT path (not just "somewhere under the root") must compare against the resolved path. + $realTargetFolder = Join-Path $rootFolder "realTarget" + $linkedFolder = Join-Path $rootFolder "linkedFolder" + $path = Join-Path $linkedFolder "file.txt" try { - New-Item -ItemType Directory -Path $internalFolderPath -Force | Out-Null - New-Item -ItemType Junction -Path $internalLinkPath -Target $internalFolderPath -Force | Out-Null - - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true - } - finally { - Remove-Item -Path $internalFolderPath, $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue - } - } + New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null - It 'Test-PathPhysicallyContained returns false through a single junction that points outside the root' -Skip:(-not $script:isWindowsPlatform) { - $internalLinkPath = Join-Path $rootFolder "link" - $path = Join-Path $internalLinkPath "file.txt" - try { - New-Item -ItemType Junction -Path $internalLinkPath -Target $externalFolder -Force | Out-Null + $resolved = Resolve-PhysicalPath -Path $path -RootFolder $rootFolder - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + Test-PathLexicallyContained -Path $resolved -RootFolder $rootFolder | Should -Be $true + $resolved | Should -Be (Join-Path $realTargetFolder "file.txt") + $resolved | Should -Not -Be ([System.IO.Path]::GetFullPath($path)) } finally { - Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $linkedFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realTargetFolder -Recurse -Force -ErrorAction SilentlyContinue } } +} - It 'Test-PathPhysicallyContained returns true through a chain of two junctions landing inside the root' -Skip:(-not $script:isWindowsPlatform) { - $internalFolderPath = Join-Path $rootFolder "folder" - $internalLinkPath1 = Join-Path $rootFolder "link1" - $internalLinkPath2 = Join-Path $rootFolder "link2" - $path = Join-Path $internalLinkPath1 "file.txt" - try { - New-Item -ItemType Directory -Path $internalFolderPath -Force | Out-Null - New-Item -ItemType Junction -Path $internalLinkPath2 -Target $internalFolderPath -Force | Out-Null - New-Item -ItemType Junction -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null +Describe "Test-PathPhysicallyEqual" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true - } - finally { - Remove-Item -Path $internalFolderPath,$internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue - } + $rootFolder = Join-Path $PSScriptRoot "physicallyEqualTests" + $externalFolder = Join-Path $PSScriptRoot "physicallyEqualTestsExternal" + New-Item -Path $rootFolder -ItemType Directory -Force | Out-Null + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null } - It 'Test-PathPhysicallyContained returns true through a chain of two junctions going outside but landing back inside the root' -Skip:(-not $script:isWindowsPlatform) { - $internalFolderPath = Join-Path $rootFolder "folder" - $internalLinkPath1 = Join-Path $rootFolder "link1" - $externalLinkPath2 = Join-Path $externalFolder "link2" - $path = Join-Path $internalLinkPath1 "file.txt" - try { - New-Item -ItemType Directory -Path $internalFolderPath -Force | Out-Null - New-Item -ItemType Junction -Path $externalLinkPath2 -Target $internalFolderPath -Force | Out-Null - New-Item -ItemType Junction -Path $internalLinkPath1 -Target $externalLinkPath2 -Force | Out-Null - - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true - } - finally { - Remove-Item -Path $internalFolderPath, $internalLinkPath1, $externalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue - } + AfterAll { + Remove-Item -Path $rootFolder, $externalFolder -Recurse -Force -ErrorAction SilentlyContinue } - It 'Test-PathPhysicallyContained returns false through a chain of two junctions landing outside the root' -Skip:(-not $script:isWindowsPlatform) { - $internalLinkPath1 = Join-Path $rootFolder "link1" - $internalLinkPath2 = Join-Path $rootFolder "link2" - $path = Join-Path $internalLinkPath1 "file.txt" - try { - New-Item -ItemType Junction -Path $internalLinkPath2 -Target $externalFolder -Force | Out-Null - New-Item -ItemType Junction -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null - - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false - } - finally { - Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue - } + It 'Test-PathPhysicallyEqual returns true when there are no reparse points' { + $path = Join-Path $rootFolder "folder/file.txt" + Test-PathPhysicallyEqual -Path $path -RootFolder $rootFolder | Should -Be $true } - It 'Test-PathPhysicallyContained returns false when a junction target embeds another junction that escapes the root as a non-final segment' -Skip:(-not $script:isWindowsPlatform) { - $internalLinkPath1 = Join-Path $rootFolder "link1" - $internalLinkPath2 = Join-Path $rootFolder "link2" - $externalSubFolder = Join-Path $externalFolder "sub" - $path = Join-Path $internalLinkPath1 "file.txt" - try { - New-Item -Path $externalSubFolder -ItemType Directory -Force | Out-Null - New-Item -ItemType Junction -Path $internalLinkPath2 -Target $externalFolder -Force | Out-Null - # link1's target textually starts under the root, but embeds link2 (which escapes the root) as a non-final segment - New-Item -ItemType Junction -Path $internalLinkPath1 -Target (Join-Path $internalLinkPath2 "sub") -Force | Out-Null - - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false - } - finally { - Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $externalSubFolder -Recurse -Force -ErrorAction SilentlyContinue - } + It 'Test-PathPhysicallyEqual returns true when the path does not exist yet' { + $path = Join-Path $rootFolder "doesNotExist/file.txt" + Test-PathPhysicallyEqual -Path $path -RootFolder $rootFolder | Should -Be $true } - - It 'Test-PathPhysicallyContained returns true through a single dangling junctions that stays within the root' -Skip:(-not $script:isWindowsPlatform) { - $internalDanglingFolderPath = Join-Path $rootFolder "dangling" - $internalLinkPath = Join-Path $rootFolder "link" - $path = Join-Path $internalLinkPath "file.txt" - try { - New-Item -ItemType Directory -Path $internalDanglingFolderPath -Force | Out-Null - New-Item -ItemType Junction -Path $internalLinkPath -Target $internalDanglingFolderPath -Force | Out-Null - Remove-Item -Path $internalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue - - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true - } - finally { - Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $internalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue - } + It 'Test-PathPhysicallyEqual returns false for a path lexically outside the root' { + $path = Join-Path $externalFolder "file.txt" + Test-PathPhysicallyEqual -Path $path -RootFolder $rootFolder | Should -Be $false } - It 'Test-PathPhysicallyContained returns false through a single dangling junctions that points outside the root' -Skip:(-not $script:isWindowsPlatform) { - $externalDanglingFolderPath = Join-Path $externalFolder "dangling" + It 'Test-PathPhysicallyEqual returns false through a symlink that points outside the root' -Skip:(-not $script:hasSymlinkCapability) { $internalLinkPath = Join-Path $rootFolder "link" $path = Join-Path $internalLinkPath "file.txt" try { - New-Item -ItemType Directory -Path $externalDanglingFolderPath -Force | Out-Null - New-Item -ItemType Junction -Path $internalLinkPath -Target $externalDanglingFolderPath -Force | Out-Null - Remove-Item -Path $externalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $externalFolder -Force | Out-Null - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + Test-PathPhysicallyEqual -Path $path -RootFolder $rootFolder | Should -Be $false } finally { Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $externalDanglingFolderPath -Recurse -Force -ErrorAction SilentlyContinue - } - } - - It 'Test-PathPhysicallyContained returns false and completes without hanging for a cyclic junction pair' -Skip:(-not $script:isWindowsPlatform) { - $internalLinkPath1 = Join-Path $rootFolder "link1" - $internalLinkPath2 = Join-Path $rootFolder "link2" - $path = Join-Path $internalLinkPath1 "file.txt" - try { - New-Item -ItemType Directory -Path $internalLinkPath1 -Force | Out-Null - New-Item -ItemType Junction -Path $internalLinkPath2 -Target $internalLinkPath1 -Force | Out-Null - Remove-Item -Path $internalLinkPath1 -Recurse -Force - New-Item -ItemType Junction -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null - Mock OutputWarning {} - - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false - - Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "Path '$path' could not be resolved: reparse point chain exceeded * hops (cyclic or too deep) at '*'. Treating as not contained." } - } - finally { - Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue } } - # Hard link tests (no elevation needed on any platform) - - It 'Test-PathPhysicallyContained returns true for a hard-linked file inside the root' { - $internalFilePath = Join-Path $rootFolder "file.txt" - $internalLinkPath = Join-Path $rootFolder "link.txt" - $path = $internalLinkPath + It 'Test-PathPhysicallyEqual returns false through a symlink that redirects to a DIFFERENT location still inside the root' -Skip:(-not $script:hasSymlinkCapability) { + $realTargetFolder = Join-Path $rootFolder "realTarget" + $linkedFolder = Join-Path $rootFolder "linkedFolder" + $path = Join-Path $linkedFolder "file.txt" try { - New-Item -ItemType File -Path $internalFilePath -Force | Out-Null - New-Item -ItemType HardLink -Path $internalLinkPath -Target $internalFilePath -Force | Out-Null - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true - } - finally { - Remove-Item -Path $internalFilePath, $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue - } - } + New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null - It 'Test-PathPhysicallyContained returns true for a hard-linked file outside the root' { - $externalFilePath = Join-Path $externalFolder "file.txt" - $internalLinkPath = Join-Path $rootFolder "link.txt" - $path = $internalLinkPath - try { - New-Item -ItemType File -Path $externalFilePath -Force | Out-Null - New-Item -ItemType HardLink -Path $internalLinkPath -Target $externalFilePath -Force | Out-Null - Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + Test-PathPhysicallyEqual -Path $path -RootFolder $rootFolder | Should -Be $false + Resolve-PhysicalPath -Path $path -RootFolder $rootFolder | Should -Be (Join-Path $realTargetFolder "file.txt") } finally { - Remove-Item -Path $externalFilePath, $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $linkedFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realTargetFolder -Recurse -Force -ErrorAction SilentlyContinue } } } From cf8671f2af87c1630196cac987576d5fe73d3cbf Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Thu, 24 Sep 2026 19:33:36 +0200 Subject: [PATCH 25/34] reworked symlink/junction handling and tests --- .../CheckForUpdates.HelperFunctions.ps1 | 110 ++-- Actions/CheckForUpdates/CheckForUpdates.ps1 | 28 +- Tests/CheckForUpdates.Action.Test.ps1 | 556 +++++++++++++++--- 3 files changed, 526 insertions(+), 168 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index e076492cc5..aa8b7926dc 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -798,9 +798,9 @@ Verifies that $Path is located under $RootFolder purely by string/segment resolu (via [System.IO.Path]::GetFullPath()) - no filesystem access, so it does not detect escapes via symlinks/junctions. Both parameters are treated as literal paths (no wildcard expansion). .PARAMETER Path -The literal path to check. +The literal path to check. If not rooted, it is considered relative to the root folder. .PARAMETER RootFolder -The literal root folder that $Path must be contained within. +The literal root folder that $Path must be contained within. Defaults to the current location if not specified. .OUTPUTS $true if the path is lexically contained within the root folder, otherwise $false. #> @@ -808,13 +808,16 @@ function Test-PathLexicallyContained { Param( [Parameter(Mandatory=$true)] [string] $Path, - [Parameter(Mandatory=$true)] - [string] $RootFolder + [string] $RootFolder = (Get-Location).Path ) $pathComparison = GetPathStringComparison - $Path = [System.IO.Path]::GetFullPath($Path) # canonicalize the path to an absolute path (resolves ".."/".") + if (-not [System.IO.Path]::IsPathRooted($Path)) { + $Path = Join-Path $RootFolder $Path # resolve relative to the root folder + } + $Path = [System.IO.Path]::GetFullPath($Path) # canonicalize the path to an absolute path + $RootFolder = [System.IO.Path]::GetFullPath($RootFolder) # canonicalize the root folder to an absolute path $RootFolder = Join-Path $RootFolder '' # ensure the root folder path ends with a directory separator @@ -825,51 +828,42 @@ function Test-PathLexicallyContained { .SYNOPSIS Resolves a path to its final physical location, following any symbolic links/junctions along the way. .DESCRIPTION -Walks $Path segment by segment starting from $RootFolder (ancestors at or above $RootFolder are assumed to -already be free of reparse points and are not resolved), following any symbolic link or junction encountered -so the result reflects where the OS would actually read/write - not just the literal path segments. This -also catches a reparse point that redirects to another location INSIDE $RootFolder (e.g. ".github" being a -symlink to "ProjectA/.github"): the resolved path differs from the literal $Path even though it never -escapes $RootFolder, which plain containment checks would miss. Both parameters are treated as literal -paths (no wildcard expansion). +Walks $Path segment by segment starting from the most specific verified folder that lexically contains it, +resolving any symbolic links or junctions encountered along the way. .PARAMETER Path -The literal path to resolve. Must be lexically contained within $RootFolder. -.PARAMETER RootFolder -The root folder above which no further reparse-point resolution is needed/performed. +The literal path to resolve. +.PARAMETER AnchorFolders +An array of anchor folders to start resolution from; reparse points within these folders are ignored (or they are otherwise known to be free of reparse points). +The function will start resolution from the most specific folder in this list that lexically contains $Path. .OUTPUTS -The fully resolved physical path, or $null if resolution failed (path not lexically contained within -$RootFolder, or a reparse point chain exceeded the hop limit). +The fully resolved physical path, or $null if resolution failed +(path not lexically contained within any of the anchor folders, or a reparse point chain exceeded the hop limit). #> function Resolve-PhysicalPath { Param( [Parameter(Mandatory=$true)] [string] $Path, - [Parameter(Mandatory=$true)] - [string] $RootFolder + [string[]] $AnchorFolders = @() ) - if (-not (Test-PathLexicallyContained -Path $Path -RootFolder $RootFolder)) { - return $null - } - $Path = [System.IO.Path]::GetFullPath($Path) # canonicalize the path to an absolute path - $RootFolder = [System.IO.Path]::GetFullPath($RootFolder) # canonicalize the root folder to an absolute path - $RootFolder = Join-Path $RootFolder '' # ensure the root folder path ends with a directory separator - $resolveReparsePoints = $true # once an ancestor doesn't exist, no deeper segment can be a reparse point either $hopLimit = 40 # matches the classic OS/.NET max-followed-symlinks limit (guards against cyclic chains) $hopCount = 0 - # List of verified paths that have been confirmed to contain no unresolved reparse points. - $verifiedPaths = [System.Collections.Generic.List[string]]::new() - $verifiedPaths.Add($RootFolder) + # Determine the most specific anchor folder that lexically contains the target path. This helps to minimize redundant checks for already verified path segments. + $anchorPath = $AnchorFolders | Sort-Object -Descending | Where-Object { Test-PathLexicallyContained -Path $Path -RootFolder $_ } | Select-Object -First 1 + if (-not $anchorPath) { + # If no anchor folder matches the target, start verification from the root of the target path. + $anchorPath = [System.IO.Path]::GetPathRoot($Path) + } + $anchorPath = Join-Path $anchorPath '' # Ensure the anchor path ends with a directory separator # Initialize the work queue of remaining path segments to walk. $segments = [System.Collections.Generic.List[string]]::new() - $segments.AddRange([string[]] $Path.Substring($RootFolder.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) - - $realPath = $RootFolder + $segments.AddRange([string[]] $Path.Substring($anchorPath.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) + $realPath = $anchorPath while ($segments.Count -gt 0) { $realPath = Join-Path $realPath $segments[0] $segments.RemoveAt(0) @@ -885,32 +879,36 @@ function Resolve-PhysicalPath { } if ($item.LinkType -notin @('SymbolicLink', 'Junction') -or -not $item.Target) { - $verifiedPaths.Add((Join-Path $realPath '')) # this segment itself is confirmed not a reparse point + if ($item -is [System.IO.DirectoryInfo]) { + $AnchorFolders += $item.FullName # this segment itself is confirmed not a reparse point + } continue } + if ($hopCount++ -gt $hopLimit) { # Cyclic or pathologically deep chain - fail closed, like the OS would refuse to resolve it too OutputWarning "Path '$Path' could not be resolved: reparse point chain exceeded $($hopLimit) hops (cyclic or too deep) at '$realPath'. Treating as not contained." return $null } - $target = @($item.Target)[0] - if (-not [System.IO.Path]::IsPathRooted($target)) { - $target = Join-Path (Split-Path -Path $realPath -Parent) $target + $targetPath = @($item.Target)[0] + if (-not [System.IO.Path]::IsPathRooted($targetPath)) { + $targetPath = Join-Path $item.Parent.FullName $targetPath } - $target = [System.IO.Path]::GetFullPath($target) + $targetPath = [System.IO.Path]::GetFullPath($targetPath) - # Find the longest verified path that is a prefix of the target. This helps to minimize redundant checks for already verified path segments. - $verifiedPath = $verifiedPaths | Sort-Object -Descending | Where-Object { Test-PathLexicallyContained -Path $target -RootFolder $_ } | Select-Object -First 1 - if (-not $verifiedPath) { - # If no verified path matches the target, start verification from the root of the target path. - $verifiedPath = [System.IO.Path]::GetPathRoot($target) + # Determine the most specific anchor folder that lexically contains the target path. This helps to minimize redundant checks for already verified path segments. + $anchorPath = $AnchorFolders | Sort-Object -Descending | Where-Object { Test-PathLexicallyContained -Path $targetPath -RootFolder $_ } | Select-Object -First 1 + if (-not $anchorPath) { + # If no anchor folder matches the target, start verification from the root of the target path. + $anchorPath = [System.IO.Path]::GetPathRoot($targetPath) } + $anchorPath = Join-Path $anchorPath '' # Ensure the anchor path ends with a directory separator # Re-inject every unverified segment of the resolved target so an embedded reparse point (e.g. link1 -> - # "link2/sub" where link2 itself escapes the root) gets its own check on a later iteration. - $segments.InsertRange(0, [string[]] $target.Substring($verifiedPath.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) - $realPath = $verifiedPath + # "link2/sub" where link2 itself escapes the root) gets its own resolve on a later iteration. + $segments.InsertRange(0, [string[]] $targetPath.Substring($anchorPath.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) + $realPath = $anchorPath } return $realPath @@ -920,15 +918,11 @@ function Resolve-PhysicalPath { .SYNOPSIS Checks whether a path physically resolves to itself, i.e. no reparse point along the way redirects it. .DESCRIPTION -Resolves $Path (see Resolve-PhysicalPath) and compares the result to $Path itself. This requires the -resolved path to be the EXACT path given - catching a reparse point that redirects to a different, -still-in-bounds location (e.g. an ancestor directory symlinked to another folder within the same root), -which plain lexical/physical containment checks would not detect. Use this to guard a destination that -must be written to/removed at the exact intended path. +Resolves $Path (see Resolve-PhysicalPath) and compares the result to $Path itself. .PARAMETER Path The literal path expected to be its own final physical location. -.PARAMETER RootFolder -The root folder above which no further reparse-point resolution is needed/performed. +.PARAMETER AnchorFolders +An array of anchor folders to start resolution from; reparse points within these folders are ignored (or they are otherwise known to be free of reparse points). .OUTPUTS $true if $Path resolves to itself, otherwise $false. #> @@ -936,17 +930,19 @@ function Test-PathPhysicallyEqual { Param( [Parameter(Mandatory=$true)] [string] $Path, - [Parameter(Mandatory=$true)] - [string] $RootFolder + [string[]] $AnchorFolders = @() ) - $realPath = Resolve-PhysicalPath -Path $Path -RootFolder $RootFolder + $pathComparer = GetPathStringComparer + + $Path = [System.IO.Path]::GetFullPath($Path) + + $realPath = Resolve-PhysicalPath -Path $Path -AnchorFolders $AnchorFolders if (-not $realPath) { return $false } - $pathComparer = GetPathStringComparer - return $pathComparer.Equals($realPath, [System.IO.Path]::GetFullPath($Path)) + return $pathComparer.Equals($realPath, $Path) } <# diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index f684661da0..99ad06b9fc 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -139,6 +139,12 @@ if ($projects.Count -gt 1) { Write-Host "Calculated dependency depth to be $depth" } +# Prepare the list of template folders to be used for verification +$templateFolders = @($templateFolder) +if ($originalTemplateFolder) { + $templateFolders += $originalTemplateFolder +} + # Loop through all folders in CheckFiles and check if there are any files that needs to be updated foreach($fileToInclude in $filesToInclude) { $type = $fileToInclude.type @@ -148,21 +154,15 @@ foreach($fileToInclude in $filesToInclude) { $originalSrcPath = $srcPath } - # Skip files that do not physically resolve to themselves within the template or original template folders - if (Test-PathLexicallyContained -Path $srcPath -RootFolder $templateFolder -and -not (Test-PathPhysicallyEqual -Path $srcPath -RootFolder $templateFolder)) { - OutputWarning "Skipping file '$srcPath': source does not physically resolve to itself within the template folder. This may indicate a symlink/junction redirect." - continue - } elseif (Test-PathLexicallyContained -Path $srcPath -RootFolder $originalTemplateFolder -and -not (Test-PathPhysicallyEqual -Path $srcPath -RootFolder $originalTemplateFolder)) { - OutputWarning "Skipping file '$srcPath': source does not physically resolve to itself within the original template folder. This may indicate a symlink/junction redirect." + # Skip files that do not physically resolve to themselves within the template folders + if (-not (Test-PathPhysicallyEqual -Path $srcPath -AnchorFolders $templateFolders)) { + OutputWarning "Skipping file '$srcPath': source does not physically resolve to itself within the template folder(s). This may indicate a symlink/junction redirect." continue } if ($originalSrcPath -ne $srcPath) { - # Skip files with original files that do not physically resolve to themselves within the template or original template folders - if (Test-PathLexicallyContained -Path $originalSrcPath -RootFolder $templateFolder -and -not (Test-PathPhysicallyEqual -Path $originalSrcPath -RootFolder $templateFolder)) { - OutputWarning "Skipping file '$srcPath': original source '$originalSrcPath' does not physically resolve to itself within the template folder. This may indicate a symlink/junction redirect." - continue - } elseif (Test-PathLexicallyContained -Path $originalSrcPath -RootFolder $originalTemplateFolder -and -not (Test-PathPhysicallyEqual -Path $originalSrcPath -RootFolder $originalTemplateFolder)) { - OutputWarning "Skipping file '$srcPath': original source '$originalSrcPath' does not physically resolve to itself within the original template folder. This may indicate a symlink/junction redirect." + # Skip files with original files that do not physically resolve to themselves within the template folders + if (-not (Test-PathPhysicallyEqual -Path $originalSrcPath -AnchorFolders $templateFolders)) { + OutputWarning "Skipping file '$srcPath': original source '$originalSrcPath' does not physically resolve to itself within the template folder(s). This may indicate a symlink/junction redirect." continue } } @@ -283,7 +283,7 @@ else { $releaseNotes = "" $updateFiles | ForEach-Object { # Skip files that do not physically resolve to themselves within the destination root folder - if (-not (Test-PathPhysicallyEqual -Path $_.DstFile -RootFolder $dstRoot)) { + if (-not (Test-PathPhysicallyEqual -Path (Join-Path $dstRoot $_.DstFile) -AnchorFolders @($dstRoot))) { OutputWarning "Skipping update of '$($_.DstFile)': destination does not physically resolve to itself. This may indicate a symlink/junction redirect." return } @@ -316,7 +316,7 @@ else { } $removeFiles | ForEach-Object { # Skip files that do not physically resolve to themselves within the destination root folder - if (-not (Test-PathPhysicallyEqual -Path $_ -RootFolder $dstRoot)) { + if (-not (Test-PathPhysicallyEqual -Path (Join-Path $dstRoot $_) -AnchorFolders @($dstRoot))) { OutputWarning "Skipping removal of '$_': destination does not physically resolve to itself. This may indicate a symlink/junction redirect." return } diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 4e5d43dba7..c28ea73886 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -71,6 +71,145 @@ Describe "CheckForUpdates Action Tests" { } } +Describe "CheckForUpdates Action: physical path guards" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'scriptPath', Justification = 'False positive.')] + $scriptPath = Join-Path $scriptRoot "$actionName.ps1" + . (Join-Path -Path $scriptRoot -ChildPath "..\AL-Go-Helper.ps1" -Resolve) + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + + $rootFolder = Join-Path $PSScriptRoot "checkForUpdatesGuardTests" + New-Item -Path $rootFolder -ItemType Directory -Force | Out-Null + } + + AfterAll { + Remove-Item -Path $rootFolder -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'CheckForUpdates skips source files that do not physically resolve to themselves within the template folder(s)' -Skip:(-not $script:isWindowsPlatform) { + $testTemplateFolder = Join-Path $rootFolder "srcGuardTemplate" + $testExternalFolder = Join-Path $rootFolder "srcGuardExternal" + $testWorkspaceFolder = Join-Path $rootFolder "srcGuardWorkspace" + $originalGitHubWorkspace = $env:GITHUB_WORKSPACE + try { + New-Item -Path $testTemplateFolder -ItemType Directory -Force | Out-Null + New-Item -Path $testExternalFolder -ItemType Directory -Force | Out-Null + New-Item -Path $testWorkspaceFolder -ItemType Directory -Force | Out-Null + + # A legitimate file directly in the template folder + $legitFile = Join-Path $testTemplateFolder "legit.txt" + Set-Content -LiteralPath $legitFile -Value "legit content" + + # A file reachable only through a junction subfolder that redirects outside the template folder - + # the target file doesn't need to exist for the guard to trip, the folder-level redirect is enough + New-Item -ItemType Junction -Path (Join-Path $testTemplateFolder "redirectSub") -Target $testExternalFolder -Force | Out-Null + $redirectedFile = Join-Path $testTemplateFolder "redirectSub/redirected.txt" + Set-Content -LiteralPath $redirectedFile -Value "redirected content" + + $testFilesToInclude = @( + @{ sourceFullPath = $legitFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "legit.txt") } + @{ sourceFullPath = $redirectedFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirected.txt") } + ) + + Mock DownloadAndImportBcContainerHelper {} + Mock ReadSettings { [PSCustomObject]@{ templateSha = 'aaaaaaa'; templateUrl = 'https://github.com/contoso/template@main'; type = 'PTE'; projects = @() } } + Mock DownloadTemplateRepository { return $testTemplateFolder } + Mock GetSrcFolder { return $testTemplateFolder } + Mock IsDirectALGo { return $true } + Mock GetProjectsFromRepository { return @('.') } + Mock GetFilesToUpdate { Write-Output -NoEnumerate $testFilesToInclude; Write-Output -NoEnumerate @() } + Mock OutputWarning {} + Mock OutputNotice {} + + $env:GITHUB_WORKSPACE = $testWorkspaceFolder + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update N + + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*redirected.txt*does not physically resolve*" } + $updateFiles.Count | Should -Be 1 + $updateFiles[0].DstFile | Should -Be "legit.txt" + } + finally { + $env:GITHUB_WORKSPACE = $originalGitHubWorkspace + Remove-Item -Path $testTemplateFolder, $testExternalFolder, $testWorkspaceFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'CheckForUpdates skips destination files that do not physically resolve to themselves when updating or removing files' -Skip:(-not $script:isWindowsPlatform) { + $testTemplateFolder = Join-Path $rootFolder "dstGuardTemplate" + $testExternalFolder = Join-Path $rootFolder "dstGuardExternal" + $testBaseFolder = Join-Path $rootFolder "dstGuardBase" + $testCloneRoot = Join-Path $rootFolder "dstGuardClone" + $originalGitHubWorkspace = $env:GITHUB_WORKSPACE + $originalLocation = Get-Location + try { + New-Item -Path $testTemplateFolder -ItemType Directory -Force | Out-Null + New-Item -Path $testExternalFolder -ItemType Directory -Force | Out-Null + New-Item -Path $testBaseFolder -ItemType Directory -Force | Out-Null + New-Item -Path $testCloneRoot -ItemType Directory -Force | Out-Null + + # Source files in the "template" + $legitSrcFile = Join-Path $testTemplateFolder "legit.txt" + Set-Content -LiteralPath $legitSrcFile -Value "legit content" + $trapSrcFile = Join-Path $testTemplateFolder "trap.txt" + Set-Content -LiteralPath $trapSrcFile -Value "trap content" + + # A file already present in the original checkout, slated for removal + New-Item -Path (Join-Path $testBaseFolder "redirectSub") -ItemType Directory -Force | Out-Null + Set-Content -LiteralPath (Join-Path $testBaseFolder "redirectSub/oldfile.txt") -Value "old content" + + # In the FRESH clone only, "redirectSub" is a junction that redirects outside the clone root - + # simulates a reparse point introduced by a newer commit that wasn't present in the original checkout + New-Item -ItemType Junction -Path (Join-Path $testCloneRoot "redirectSub") -Target $testExternalFolder -Force | Out-Null + Set-Content -LiteralPath (Join-Path $testExternalFolder "oldfile.txt") -Value "cloned old content" + + $testFilesToInclude = @( + @{ sourceFullPath = $legitSrcFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testBaseFolder "legit.txt") } + @{ sourceFullPath = $trapSrcFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testBaseFolder "redirectSub/trap.txt") } + ) + $testFilesToExclude = @( + @{ destinationFullPath = (Join-Path $testBaseFolder "redirectSub/oldfile.txt") } + ) + + Mock DownloadAndImportBcContainerHelper {} + Mock ReadSettings { [PSCustomObject]@{ templateSha = 'aaaaaaa'; templateUrl = 'https://github.com/contoso/template@main'; type = 'PTE'; projects = @() } } + Mock DownloadTemplateRepository { return $testTemplateFolder } + Mock GetSrcFolder { return $testTemplateFolder } + Mock IsDirectALGo { return $true } + Mock GetProjectsFromRepository { return @('.') } + Mock GetFilesToUpdate { Write-Output -NoEnumerate $testFilesToInclude; Write-Output -NoEnumerate $testFilesToExclude } + Mock OutputWarning {} + Mock OutputNotice {} + Mock RunAndCheck { return "deadbeef1234567" } + Mock invoke-git {} + Mock GetAccessToken { return "fake-token" } + Mock gh { '[]' } + Mock CloneIntoNewFolder { Set-Location -Path $testCloneRoot; 'https://fake.example.com/repo.git'; 'update-al-go-system-files/branch' } + Mock CommitFromNewFolder { return $true } + Mock UpdateSettingsFile {} + + $env:GITHUB_WORKSPACE = $testBaseFolder + $fakeToken = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("fake-pat")) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update Y -updateBranch "update-al-go-system-files" -token $fakeToken -directCommit $true -actor "test-actor" + + $updateFiles.Count | Should -Be 2 + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*Skipping update*trap.txt*" } + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*Skipping removal*oldfile.txt*" } + + # The legitimate update was actually written to the (mocked) clone root; the trapped one was not + Test-Path -Path (Join-Path $testCloneRoot "legit.txt") -PathType Leaf | Should -Be $true + } + finally { + Set-Location -Path $originalLocation + $env:GITHUB_WORKSPACE = $originalGitHubWorkspace + Remove-Item -Path $testTemplateFolder, $testExternalFolder, $testBaseFolder, $testCloneRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + Describe "YamlClass Tests" { BeforeAll { $actionName = "CheckForUpdates" @@ -1668,7 +1807,7 @@ Describe "ResolveFilePaths" { } } - It 'ResolveFilePaths skips files in a source folder that differs only by case' -Skip:(-not $script:isLinuxPlatform) { + It 'ResolveFilePaths skips files in a source folder that differs only by case on Linux' -Skip:(-not $script:isLinuxPlatform) { $externalFolder = Join-Path $rootFolder 'sourcefolder' $externalFile = Join-Path $externalFolder 'outside.txt' $destinationFolder = Join-Path $rootFolder 'destinationFolder' @@ -1690,7 +1829,7 @@ Describe "ResolveFilePaths" { } } - It 'ResolveFilePaths skips destinations in a folder that differs only by case' -Skip:(-not $script:isLinuxPlatform) { + It 'ResolveFilePaths skips destinations in a folder that differs only by case on Linux' -Skip:(-not $script:isLinuxPlatform) { $destinationFolder = Join-Path $rootFolder 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = '../casefolder/outside.txt' } @@ -1703,7 +1842,7 @@ Describe "ResolveFilePaths" { Should -Invoke OutputWarning -Times 1 } - It 'ResolveFilePaths skips per-project destinations in a folder that differs only by case' -Skip:(-not $script:isLinuxPlatform) { + It 'ResolveFilePaths skips per-project destinations in a folder that differs only by case on Linux' -Skip:(-not $script:isLinuxPlatform) { $destinationFolder = Join-Path $rootFolder 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = ''; 'destinationName' = '../caseproject/outside.txt'; 'perProject' = $true } @@ -1799,7 +1938,7 @@ Describe "ResolveFilePaths" { $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") } - It 'ResolveFilePaths keeps case-distinct destination entries on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { + It 'ResolveFilePaths keeps case-distinct destination entries on Linux' -Skip:(-not $script:isLinuxPlatform) { $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt' } @@ -1813,7 +1952,7 @@ Describe "ResolveFilePaths" { ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'casefolder/conflict.txt')) | Should -BeTrue } - It 'ResolveFilePaths removes case-distinct destination entries on case-insensitive platforms' -Skip:$script:isLinuxPlatform { + It 'ResolveFilePaths removes case-distinct destination entries on Windows' -Skip:(-not $script:isWindowsPlatform) { $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt' } @@ -1979,7 +2118,7 @@ Describe "ResolveFilePaths" { $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectA/folder/File1.txt") } - It 'ResolveFilePaths keeps case-distinct per-project destination entries on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { + It 'ResolveFilePaths keeps case-distinct per-project destination entries on Linux' -Skip:(-not $script:isLinuxPlatform) { $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } @@ -1993,7 +2132,7 @@ Describe "ResolveFilePaths" { ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/casefolder/conflict.txt')) | Should -BeTrue } - It 'ResolveFilePaths removes case-distinct per-project destination entries on case-insensitive platforms' -Skip:$script:isLinuxPlatform { + It 'ResolveFilePaths removes case-distinct per-project destination entries on Windows' -Skip:(-not $script:isWindowsPlatform) { $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' $files = @( @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } @@ -2115,7 +2254,7 @@ Describe "ResolveFilePaths" { $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File2.log") } - It 'ResolveFilePaths handles case-insensitive filter matching on Windows' { + It 'ResolveFilePaths handles case-insensitive filter matching' { # Create files with different case $upperFile = Join-Path $sourceFolder "folder/UPPER.TXT" $lowerFile = Join-Path $sourceFolder "folder/lower.txt" @@ -2130,10 +2269,9 @@ Describe "ResolveFilePaths" { $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder - # On Windows, both should match due to case-insensitive file system $fullFilePaths | Should -Not -BeNullOrEmpty - $upperMatch = $fullFilePaths | Where-Object { $_.sourceFullPath -eq $upperFile } - $lowerMatch = $fullFilePaths | Where-Object { $_.sourceFullPath -eq $lowerFile } + $upperMatch = $fullFilePaths | Where-Object { $_.sourceFullPath -ceq $upperFile } + $lowerMatch = $fullFilePaths | Where-Object { $_.sourceFullPath -ceq $lowerFile } $upperMatch | Should -Not -BeNullOrEmpty $lowerMatch | Should -Not -BeNullOrEmpty } @@ -2160,12 +2298,12 @@ Describe "Test-PathLexicallyContained" { Remove-Item -Path $rootFolder, $externalFolder -Recurse -Force -ErrorAction SilentlyContinue } - It 'Test-PathLexicallyContained returns true for a path lexically inside the root folder (no I/O involved)' { + It 'Test-PathLexicallyContained returns true for a path lexically inside the root folder' { $path = Join-Path $rootFolder "folder/file.txt" Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true } - It 'Test-PathLexicallyContained returns false for a path lexically outside the root folder (no I/O involved)' { + It 'Test-PathLexicallyContained returns false for a path lexically outside the root folder' { $path = Join-Path $externalFolder "file.txt" Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false } @@ -2185,30 +2323,6 @@ Describe "Test-PathLexicallyContained" { Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true } - It 'Test-PathLexicallyContained does not require the path to exist' { - $path = Join-Path $rootFolder "doesNotExist/file.txt" - Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true - } - - It 'Test-PathLexicallyContained does not perform filesystem/symlink resolution, unlike Test-PathPhysicallyEqual' -Skip:(-not $script:hasSymlinkCapability) { - $internalFolderPath = Join-Path $rootFolder "folder" - $internalLinkPath = Join-Path $rootFolder "link" - $path = Join-Path $internalLinkPath "file.txt" - try { - New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $externalFolder -Force | Out-Null - - # Lexically the path looks contained (the link name itself is under $rootFolder); the lexical - # check must not follow the link to see that its target escapes - that's what Test-PathPhysicallyEqual is for. - Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true - Test-PathPhysicallyEqual -Path $path -RootFolder $rootFolder | Should -Be $false - } - finally { - Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $internalFolderPath -Recurse -Force -ErrorAction SilentlyContinue - } - } - It 'Test-PathLexicallyContained is case-insensitive on Windows' -Skip:(-not $script:isWindowsPlatform) { $path = Join-Path $rootFolder "FOLDER/file.txt" Test-PathLexicallyContained -Path $path -RootFolder (Join-Path $rootFolder "folder") | Should -Be $true @@ -2238,63 +2352,270 @@ Describe "Resolve-PhysicalPath" { It 'Resolve-PhysicalPath returns the canonicalized path when there are no reparse points' { $path = Join-Path $rootFolder "folder/file.txt" - Resolve-PhysicalPath -Path $path -RootFolder $rootFolder | Should -Be ([System.IO.Path]::GetFullPath($path)) + Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be ([System.IO.Path]::GetFullPath($path)) } - It 'Resolve-PhysicalPath returns $null for a path lexically outside the root folder (no I/O involved)' { - $path = Join-Path $externalFolder "file.txt" - Resolve-PhysicalPath -Path $path -RootFolder $rootFolder | Should -Be $null + It 'Resolve-PhysicalPath resolves a single symlink to its real target' -Skip:(-not $script:hasSymlinkCapability) { + $realTargetFolder = Join-Path $rootFolder "singleSymRealTarget" + $linkedFolder = Join-Path $rootFolder "singleSymLink" + $path = Join-Path $linkedFolder "file.txt" + try { + New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null + + $resolved = Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) + + $resolved | Should -Be (Join-Path $realTargetFolder "file.txt") + $resolved | Should -Not -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Remove-Item -Path $linkedFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } } - It 'Resolve-PhysicalPath returns $null through a symlink that points outside the root' -Skip:(-not $script:hasSymlinkCapability) { - $internalLinkPath = Join-Path $rootFolder "link" - $path = Join-Path $internalLinkPath "file.txt" + It 'Resolve-PhysicalPath resolves a single junction to its real target' -Skip:(-not $script:isWindowsPlatform) { + $realTargetFolder = Join-Path $rootFolder "singleJctRealTarget" + $linkedFolder = Join-Path $rootFolder "singleJctLink" + $path = Join-Path $linkedFolder "file.txt" try { - New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $externalFolder -Force | Out-Null + New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null + + $resolved = Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) - Resolve-PhysicalPath -Path $path -RootFolder $rootFolder | Should -Be $null + $resolved | Should -Be (Join-Path $realTargetFolder "file.txt") + $resolved | Should -Not -Be ([System.IO.Path]::GetFullPath($path)) } finally { - Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $linkedFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realTargetFolder -Recurse -Force -ErrorAction SilentlyContinue } } - It 'Resolve-PhysicalPath resolves to the real target path through a symlink that stays within the root' -Skip:(-not $script:hasSymlinkCapability) { - $internalFolderPath = Join-Path $rootFolder "folder" - $internalLinkPath = Join-Path $rootFolder "link" - $path = Join-Path $internalLinkPath "file.txt" + It 'Resolve-PhysicalPath resolves a chain of two symlinks (link1 -> link2 -> real folder) to its real target' -Skip:(-not $script:hasSymlinkCapability) { + $realFolder = Join-Path $rootFolder "chainSymRealTarget" + $link2 = Join-Path $rootFolder "chainSymLink2" + $link1 = Join-Path $rootFolder "chainSymLink1" + $path = Join-Path $link1 "file.txt" try { - New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $internalFolderPath -Force | Out-Null + New-Item -Path $realFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $link2 -Target $realFolder -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $link1 -Target $link2 -Force | Out-Null - Resolve-PhysicalPath -Path $path -RootFolder $rootFolder | Should -Be (Join-Path $internalFolderPath "file.txt") + Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be (Join-Path $realFolder "file.txt") } finally { - Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $internalFolderPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $link1 -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $link2 -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realFolder -Recurse -Force -ErrorAction SilentlyContinue } } - It 'Resolve-PhysicalPath reveals an in-root redirect that lexical containment alone would miss' -Skip:(-not $script:hasSymlinkCapability) { - # A symlink that redirects to a DIFFERENT location still inside the root: containment alone says "fine", - # but the resolved path is not the one the caller intended to read/write - callers that need the write to - # land at an EXACT path (not just "somewhere under the root") must compare against the resolved path. - $realTargetFolder = Join-Path $rootFolder "realTarget" - $linkedFolder = Join-Path $rootFolder "linkedFolder" - $path = Join-Path $linkedFolder "file.txt" + It 'Resolve-PhysicalPath resolves a chain of two junctions (link1 -> link2 -> real folder) to its real target' -Skip:(-not $script:isWindowsPlatform) { + $realFolder = Join-Path $rootFolder "chainJctRealTarget" + $link2 = Join-Path $rootFolder "chainJctLink2" + $link1 = Join-Path $rootFolder "chainJctLink1" + $path = Join-Path $link1 "file.txt" try { - New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null + New-Item -Path $realFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $link2 -Target $realFolder -Force | Out-Null + New-Item -ItemType Junction -Path $link1 -Target $link2 -Force | Out-Null - $resolved = Resolve-PhysicalPath -Path $path -RootFolder $rootFolder + Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be (Join-Path $realFolder "file.txt") + } + finally { + Remove-Item -Path $link1 -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $link2 -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } - Test-PathLexicallyContained -Path $resolved -RootFolder $rootFolder | Should -Be $true - $resolved | Should -Be (Join-Path $realTargetFolder "file.txt") - $resolved | Should -Not -Be ([System.IO.Path]::GetFullPath($path)) + It 'Resolve-PhysicalPath resolves a dangling symlink to its real target without warning' -Skip:(-not $script:hasSymlinkCapability) { + $danglingTarget = Join-Path $rootFolder "danglingSymTarget" + $linkPath = Join-Path $rootFolder "danglingSymLink" + try { + New-Item -Path $danglingTarget -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $linkPath -Target $danglingTarget -Force | Out-Null + Remove-Item -Path $danglingTarget -Force + Mock OutputWarning {} + + Resolve-PhysicalPath -Path $linkPath -AnchorFolders @($rootFolder) | Should -Be ([System.IO.Path]::GetFullPath($danglingTarget)) + Should -Invoke OutputWarning -Times 0 } finally { - Remove-Item -Path $linkedFolder -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $realTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $linkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $danglingTarget -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PhysicalPath resolves a dangling junction to its real target without warning' -Skip:(-not $script:isWindowsPlatform) { + $danglingTarget = Join-Path $rootFolder "danglingJctTarget" + $linkPath = Join-Path $rootFolder "danglingJctLink" + try { + New-Item -Path $danglingTarget -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $linkPath -Target $danglingTarget -Force | Out-Null + Remove-Item -Path $danglingTarget -Force + Mock OutputWarning {} + + Resolve-PhysicalPath -Path $linkPath -AnchorFolders @($rootFolder) | Should -Be ([System.IO.Path]::GetFullPath($danglingTarget)) + Should -Invoke OutputWarning -Times 0 + } + finally { + Remove-Item -Path $linkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $danglingTarget -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PhysicalPath returns $null and warns when a symlink chain cycles back on itself' -Skip:(-not $script:hasSymlinkCapability) { + $linkA = Join-Path $rootFolder "cyclicLinkA" + $linkB = Join-Path $rootFolder "cyclicLinkB" + try { + New-Item -Path $linkA -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $linkB -Target $linkA -Force | Out-Null + Remove-Item -Path $linkA -Force + New-Item -ItemType SymbolicLink -Path $linkA -Target $linkB -Force | Out-Null + Mock OutputWarning {} + + Resolve-PhysicalPath -Path $linkA -AnchorFolders @($rootFolder) | Should -Be $null + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*exceeded*hops*" } + } + finally { + Remove-Item -Path $linkA -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $linkB -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PhysicalPath returns $null and warns when a junction chain cycles back on itself' -Skip:(-not $script:isWindowsPlatform) { + $nodeA = Join-Path $rootFolder "cyclicJctNodeA" + $nodeB = Join-Path $rootFolder "cyclicJctNodeB" + try { + New-Item -Path $nodeA -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $nodeB -Target $nodeA -Force | Out-Null + Remove-Item -Path $nodeA -Force + New-Item -ItemType Junction -Path $nodeA -Target $nodeB -Force | Out-Null + Mock OutputWarning {} + + Resolve-PhysicalPath -Path $nodeA -AnchorFolders @($rootFolder) | Should -Be $null + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*exceeded*hops*" } + } + finally { + Remove-Item -Path $nodeA -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $nodeB -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PhysicalPath resolves a symlink whose target path has a parent symlink (link1 -> link2/subdir/file.txt)' -Skip:(-not $script:hasSymlinkCapability) { + $realFolder = Join-Path $rootFolder "nestedSymRealTarget" + $parentLink = Join-Path $rootFolder "nestedSymParentLink" + $outerLink = Join-Path $rootFolder "nestedSymOuterLink" + $path = Join-Path $outerLink "file.txt" + try { + New-Item -Path (Join-Path $realFolder "subdir") -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $parentLink -Target $realFolder -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $outerLink -Target (Join-Path $parentLink "subdir") -Force | Out-Null + + Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be (Join-Path $realFolder "subdir/file.txt") + } + finally { + Remove-Item -Path $outerLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $parentLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PhysicalPath resolves a junction whose target path has a parent junction (link1 -> link2/subdir/file.txt)' -Skip:(-not $script:isWindowsPlatform) { + $realFolder = Join-Path $rootFolder "nestedJctRealTarget" + $parentLink = Join-Path $rootFolder "nestedJctParentLink" + $outerLink = Join-Path $rootFolder "nestedJctOuterLink" + $path = Join-Path $outerLink "file.txt" + try { + New-Item -Path (Join-Path $realFolder "subdir") -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $parentLink -Target $realFolder -Force | Out-Null + New-Item -ItemType Junction -Path $outerLink -Target (Join-Path $parentLink "subdir") -Force | Out-Null + + Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be (Join-Path $realFolder "subdir/file.txt") + } + finally { + Remove-Item -Path $outerLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $parentLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PhysicalPath trusts a symlink when its own path is passed as an anchor folder, skipping resolution' -Skip:(-not $script:hasSymlinkCapability) { + $externalTargetFolder = Join-Path $externalFolder "anchorTrustSymTarget" + $trustedLink = Join-Path $rootFolder "anchorTrustSymLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + # Without the link in AnchorFolders, it is followed to its real (external) target + Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be (Join-Path $externalTargetFolder "file.txt") + + # When the link's own path is passed as an anchor, it is trusted and not followed + Resolve-PhysicalPath -Path $path -AnchorFolders @($trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PhysicalPath trusts a junction when its own path is passed as an anchor folder, skipping resolution' -Skip:(-not $script:isWindowsPlatform) { + $externalTargetFolder = Join-Path $externalFolder "anchorTrustJctTarget" + $trustedLink = Join-Path $rootFolder "anchorTrustJctLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + # Without the link in AnchorFolders, it is followed to its real (external) target + Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be (Join-Path $externalTargetFolder "file.txt") + + # When the link's own path is passed as an anchor, it is trusted and not followed + Resolve-PhysicalPath -Path $path -AnchorFolders @($trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PhysicalPath picks the most specific of multiple anchors, trusting a symlink sitting at the deeper anchor' -Skip:(-not $script:hasSymlinkCapability) { + $externalTargetFolder = Join-Path $externalFolder "multiAnchorSymTarget" + $trustedLink = Join-Path $rootFolder "multiAnchorSymLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + # A broader anchor (rootFolder) alone would not bypass resolution, but the more specific + # anchor (the link itself) is selected and trusted, even when both are supplied together + Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder, $trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PhysicalPath picks the most specific of multiple anchors, trusting a junction sitting at the deeper anchor' -Skip:(-not $script:isWindowsPlatform) { + $externalTargetFolder = Join-Path $externalFolder "multiAnchorJctTarget" + $trustedLink = Join-Path $rootFolder "multiAnchorJctLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + # A broader anchor (rootFolder) alone would not bypass resolution, but the more specific + # anchor (the link itself) is selected and trusted, even when both are supplied together + Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder, $trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue } } } @@ -2315,51 +2636,92 @@ Describe "Test-PathPhysicallyEqual" { Remove-Item -Path $rootFolder, $externalFolder -Recurse -Force -ErrorAction SilentlyContinue } + It 'Test-PathPhysicallyEqual delegates to Resolve-PhysicalPath and compares its result against the canonicalized path' { + $path = Join-Path $rootFolder "delegationCheck/file.txt" + + Mock Resolve-PhysicalPath { return $Path } + Test-PathPhysicallyEqual -Path $path -AnchorFolders @($rootFolder) | Should -Be $true + Should -Invoke Resolve-PhysicalPath -Times 1 -ParameterFilter { + $Path -eq ([System.IO.Path]::GetFullPath($path)) -and (Compare-Object $AnchorFolders @($rootFolder) | Measure-Object).Count -eq 0 + } + + Mock Resolve-PhysicalPath { return (Join-Path $rootFolder "somewhereElse/file.txt") } + Test-PathPhysicallyEqual -Path $path -AnchorFolders @($rootFolder) | Should -Be $false + } + It 'Test-PathPhysicallyEqual returns true when there are no reparse points' { $path = Join-Path $rootFolder "folder/file.txt" - Test-PathPhysicallyEqual -Path $path -RootFolder $rootFolder | Should -Be $true + Test-PathPhysicallyEqual -Path $path -AnchorFolders @($rootFolder) | Should -Be $true } It 'Test-PathPhysicallyEqual returns true when the path does not exist yet' { $path = Join-Path $rootFolder "doesNotExist/file.txt" - Test-PathPhysicallyEqual -Path $path -RootFolder $rootFolder | Should -Be $true + Test-PathPhysicallyEqual -Path $path -AnchorFolders @($rootFolder) | Should -Be $true } - It 'Test-PathPhysicallyEqual returns false for a path lexically outside the root' { - $path = Join-Path $externalFolder "file.txt" - Test-PathPhysicallyEqual -Path $path -RootFolder $rootFolder | Should -Be $false - } - - It 'Test-PathPhysicallyEqual returns false through a symlink that points outside the root' -Skip:(-not $script:hasSymlinkCapability) { - $internalLinkPath = Join-Path $rootFolder "link" - $path = Join-Path $internalLinkPath "file.txt" + It 'Test-PathPhysicallyEqual returns false when a symlink redirects to a different real location' -Skip:(-not $script:hasSymlinkCapability) { + $realTargetFolder = Join-Path $rootFolder "redirectSymTarget" + $linkedFolder = Join-Path $rootFolder "redirectSymLink" + $path = Join-Path $linkedFolder "file.txt" try { - New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $externalFolder -Force | Out-Null + New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null - Test-PathPhysicallyEqual -Path $path -RootFolder $rootFolder | Should -Be $false + Test-PathPhysicallyEqual -Path $path -AnchorFolders @($rootFolder) | Should -Be $false } finally { - Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $linkedFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realTargetFolder -Recurse -Force -ErrorAction SilentlyContinue } } - It 'Test-PathPhysicallyEqual returns false through a symlink that redirects to a DIFFERENT location still inside the root' -Skip:(-not $script:hasSymlinkCapability) { - $realTargetFolder = Join-Path $rootFolder "realTarget" - $linkedFolder = Join-Path $rootFolder "linkedFolder" + It 'Test-PathPhysicallyEqual returns false when a junction redirects to a different real location' -Skip:(-not $script:isWindowsPlatform) { + $realTargetFolder = Join-Path $rootFolder "redirectJctTarget" + $linkedFolder = Join-Path $rootFolder "redirectJctLink" $path = Join-Path $linkedFolder "file.txt" try { New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null - New-Item -ItemType SymbolicLink -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null + New-Item -ItemType Junction -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null - Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true - Test-PathPhysicallyEqual -Path $path -RootFolder $rootFolder | Should -Be $false - Resolve-PhysicalPath -Path $path -RootFolder $rootFolder | Should -Be (Join-Path $realTargetFolder "file.txt") + Test-PathPhysicallyEqual -Path $path -AnchorFolders @($rootFolder) | Should -Be $false } finally { Remove-Item -Path $linkedFolder -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path $realTargetFolder -Recurse -Force -ErrorAction SilentlyContinue } } + + It 'Test-PathPhysicallyEqual returns true when the anchor folder is itself a symlink, bypassing resolution' -Skip:(-not $script:hasSymlinkCapability) { + $externalTargetFolder = Join-Path $externalFolder "anchorTrustSymTarget" + $trustedLink = Join-Path $rootFolder "anchorTrustSymLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + Test-PathPhysicallyEqual -Path $path -AnchorFolders @($trustedLink) | Should -Be $true + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyEqual returns true when the anchor folder is itself a junction, bypassing resolution' -Skip:(-not $script:isWindowsPlatform) { + $externalTargetFolder = Join-Path $externalFolder "anchorTrustJctTarget" + $trustedLink = Join-Path $rootFolder "anchorTrustJctLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + Test-PathPhysicallyEqual -Path $path -AnchorFolders @($trustedLink) | Should -Be $true + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } } Describe "ReplaceOwnerRepoAndBranch" { @@ -2953,7 +3315,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { $conflict[0].sourceFullPath | Should -Be $testPSFile } - It 'GetFilesToUpdate keeps case-distinct destination entries on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { + It 'GetFilesToUpdate keeps case-distinct destination entries on Linux' -Skip:(-not $script:isLinuxPlatform) { $settings = @{ type = 'NotPTE' unusedALGoSystemFiles = @() @@ -2972,7 +3334,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'casefolder/conflict.txt')) | Should -BeTrue } - It 'GetFilesToUpdate excludes only the exact-case source path on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { + It 'GetFilesToUpdate excludes only the exact-case source path on Linux' -Skip:(-not $script:isLinuxPlatform) { $upperCaseFolder = Join-Path $templateFolder 'CaseFolder' $lowerCaseFolder = Join-Path $templateFolder 'casefolder' $upperCaseFile = Join-Path $upperCaseFolder 'script.ps1' @@ -3008,7 +3370,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - It 'GetFilesToUpdate excludes only the exact-case unused file on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { + It 'GetFilesToUpdate excludes only the exact-case unused file on Linux' -Skip:(-not $script:isLinuxPlatform) { $upperCaseFile = Join-Path $templateFolder 'UnusedFile.ps1' $lowerCaseFile = Join-Path $templateFolder 'unusedfile.ps1' Set-Content -Path $upperCaseFile -Value '# upper case file' @@ -3037,7 +3399,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - It 'GetFilesToUpdate removes case-distinct destination entries on case-insensitive platforms' -Skip:$script:isLinuxPlatform { + It 'GetFilesToUpdate removes case-distinct destination entries on Windows' -Skip:(-not $script:isWindowsPlatform) { $settings = @{ type = 'NotPTE' unusedALGoSystemFiles = @() @@ -3056,7 +3418,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'casefolder/conflict.txt')) | Should -BeFalse } - It 'GetFilesToUpdate excludes any case source path on case-insensitive platforms' -Skip:$script:isLinuxPlatform { + It 'GetFilesToUpdate excludes any case source path on Windows' -Skip:(-not $script:isWindowsPlatform) { $upperCaseFolder = Join-Path $templateFolder 'CaseFolder' $upperCaseFile = Join-Path $upperCaseFolder 'script.ps1' New-Item -ItemType Directory -Path $upperCaseFolder -Force | Out-Null @@ -3086,7 +3448,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - It 'GetFilesToUpdate excludes any case unused file on case-insensitive platforms' -Skip:$script:isLinuxPlatform { + It 'GetFilesToUpdate excludes any case unused file on Windows' -Skip:(-not $script:isWindowsPlatform) { $upperCaseFile = Join-Path $templateFolder 'UnusedFile.ps1' $lowerCaseFile = Join-Path $templateFolder 'unusedfile.ps1' Set-Content -Path $upperCaseFile -Value '# upper case file' From d9759edbb1ad68a7b5d5944f574da304a04055af Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Thu, 24 Sep 2026 23:13:55 +0200 Subject: [PATCH 26/34] more rework --- .../CheckForUpdates.HelperFunctions.ps1 | 157 ++++++---- Actions/CheckForUpdates/CheckForUpdates.ps1 | 10 +- Tests/CheckForUpdates.Action.Test.ps1 | 290 +++++++++++++----- 3 files changed, 314 insertions(+), 143 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index aa8b7926dc..de3d85b759 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -790,13 +790,47 @@ function GetPathStringComparer { } } +<# +.SYNOPSIS +Resolves a path to an absolute, lexically-canonicalized path (resolving ".." and "." segments). +.DESCRIPTION +[System.IO.Path]::GetFullPath() resolves a relative path against [System.Environment]::CurrentDirectory, +which Set-Location does not reliably keep in sync with PowerShell's own current location - so a bare +GetFullPath() call can silently resolve against a stale directory after Set-Location. This function instead +joins a relative $Path against PowerShell's actual current location before canonicalizing, so behavior is +correct regardless of that .NET/PowerShell CWD desync. +.PARAMETER Path +The literal path to resolve. If not rooted, it is considered relative to the current location. +.PARAMETER AsDirectory +If set, ensures the returned path ends with a directory separator (e.g. for safe prefix/StartsWith checks). +.OUTPUTS +The absolute, lexically-canonicalized path. +#> +function Resolve-PathLexically { + Param( + [Parameter(Mandatory=$true)] + [string] $Path, + [switch] $AsDirectory + ) + + if (-not [System.IO.Path]::IsPathRooted($Path)) { + $Path = Join-Path (Get-Location).Path $Path # resolve relative to the current location + } + + $Path = [System.IO.Path]::GetFullPath($Path) # canonicalize the path to an absolute path + + if ($AsDirectory) { + $Path = Join-Path $Path '' # ensure the path ends with a directory separator + } + + return $Path +} + <# .SYNOPSIS Checks whether a path is lexically contained within a root folder, resolving ".." and "." segments. .DESCRIPTION -Verifies that $Path is located under $RootFolder purely by string/segment resolution -(via [System.IO.Path]::GetFullPath()) - no filesystem access, so it does not detect escapes via -symlinks/junctions. Both parameters are treated as literal paths (no wildcard expansion). +Verifies that $Path is located under $RootFolder purely by string/segment resolution (via Resolve-PathLexically) .PARAMETER Path The literal path to check. If not rooted, it is considered relative to the root folder. .PARAMETER RootFolder @@ -813,13 +847,13 @@ function Test-PathLexicallyContained { $pathComparison = GetPathStringComparison + $RootFolder = Resolve-PathLexically -Path $RootFolder -AsDirectory + if (-not [System.IO.Path]::IsPathRooted($Path)) { $Path = Join-Path $RootFolder $Path # resolve relative to the root folder } - $Path = [System.IO.Path]::GetFullPath($Path) # canonicalize the path to an absolute path - - $RootFolder = [System.IO.Path]::GetFullPath($RootFolder) # canonicalize the root folder to an absolute path - $RootFolder = Join-Path $RootFolder '' # ensure the root folder path ends with a directory separator + # AsDirectory here (not just for $RootFolder) makes $Path itself count as contained, not just its descendants + $Path = Resolve-PathLexically -Path $Path -AsDirectory return $Path.StartsWith($RootFolder, $pathComparison) } @@ -832,38 +866,42 @@ Walks $Path segment by segment starting from the most specific verified folder t resolving any symbolic links or junctions encountered along the way. .PARAMETER Path The literal path to resolve. -.PARAMETER AnchorFolders -An array of anchor folders to start resolution from; reparse points within these folders are ignored (or they are otherwise known to be free of reparse points). -The function will start resolution from the most specific folder in this list that lexically contains $Path. +.PARAMETER AnchorPaths +An array of trusted anchor paths (folders, or specific files/reparse points) to start resolution from; +reparse points within these paths are ignored (or they are otherwise known to be free of reparse points). +The function will start resolution from the most specific entry in this list that lexically contains $Path +(or that equals $Path exactly). .OUTPUTS The fully resolved physical path, or $null if resolution failed -(path not lexically contained within any of the anchor folders, or a reparse point chain exceeded the hop limit). +(path not lexically contained within any of the anchor paths, or a reparse point chain exceeded the hop limit). #> -function Resolve-PhysicalPath { +function Resolve-PathPhysically { Param( [Parameter(Mandatory=$true)] [string] $Path, - [string[]] $AnchorFolders = @() + [string[]] $AnchorPaths = @() ) - $Path = [System.IO.Path]::GetFullPath($Path) # canonicalize the path to an absolute path + $realPath = Resolve-PathLexically -Path $Path # canonicalize the path to an absolute path + + $segments = [System.Collections.Generic.List[string]]::new() $resolveReparsePoints = $true # once an ancestor doesn't exist, no deeper segment can be a reparse point either $hopLimit = 40 # matches the classic OS/.NET max-followed-symlinks limit (guards against cyclic chains) $hopCount = 0 # Determine the most specific anchor folder that lexically contains the target path. This helps to minimize redundant checks for already verified path segments. - $anchorPath = $AnchorFolders | Sort-Object -Descending | Where-Object { Test-PathLexicallyContained -Path $Path -RootFolder $_ } | Select-Object -First 1 - if (-not $anchorPath) { - # If no anchor folder matches the target, start verification from the root of the target path. - $anchorPath = [System.IO.Path]::GetPathRoot($Path) + # Uses $realPath (already canonicalized/absolute), not the raw $Path, since GetPathRoot/Resolve-PathLexically require a rooted or resolvable path + $anchorPath = @($AnchorPaths) + @([System.IO.Path]::GetPathRoot($realPath)) | # combine user-provided anchor folders with the root of the target path + ForEach-Object { Resolve-PathLexically -Path $_ -AsDirectory } | # canonicalize each anchor folder to an absolute path as a directory + Sort-Object -Descending | # sort anchor folders by descending length to prioritize the most specific one (longest path first) + Where-Object { Test-PathLexicallyContained -Path $realPath -RootFolder $_ } | # filter only those anchor folders that lexically contain the target path + Select-Object -First 1 + + if ($realPath.Length -gt $anchorPath.Length) { + $segments.AddRange([string[]] $realPath.Substring($anchorPath.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) + $realPath = $anchorPath } - $anchorPath = Join-Path $anchorPath '' # Ensure the anchor path ends with a directory separator - - # Initialize the work queue of remaining path segments to walk. - $segments = [System.Collections.Generic.List[string]]::new() - $segments.AddRange([string[]] $Path.Substring($anchorPath.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) - $realPath = $anchorPath while ($segments.Count -gt 0) { $realPath = Join-Path $realPath $segments[0] $segments.RemoveAt(0) @@ -880,7 +918,7 @@ function Resolve-PhysicalPath { if ($item.LinkType -notin @('SymbolicLink', 'Junction') -or -not $item.Target) { if ($item -is [System.IO.DirectoryInfo]) { - $AnchorFolders += $item.FullName # this segment itself is confirmed not a reparse point + $AnchorPaths += $item.FullName # this segment itself is confirmed not a reparse point } continue } @@ -891,24 +929,25 @@ function Resolve-PhysicalPath { return $null } - $targetPath = @($item.Target)[0] - if (-not [System.IO.Path]::IsPathRooted($targetPath)) { - $targetPath = Join-Path $item.Parent.FullName $targetPath + $realPath = @($item.Target)[0] + if (-not [System.IO.Path]::IsPathRooted($realPath)) { + $realPath = Join-Path $item.Parent.FullName $realPath } - $targetPath = [System.IO.Path]::GetFullPath($targetPath) + $realPath = Resolve-PathLexically -Path $realPath # Determine the most specific anchor folder that lexically contains the target path. This helps to minimize redundant checks for already verified path segments. - $anchorPath = $AnchorFolders | Sort-Object -Descending | Where-Object { Test-PathLexicallyContained -Path $targetPath -RootFolder $_ } | Select-Object -First 1 - if (-not $anchorPath) { - # If no anchor folder matches the target, start verification from the root of the target path. - $anchorPath = [System.IO.Path]::GetPathRoot($targetPath) + $anchorPath = @($AnchorPaths) + @([System.IO.Path]::GetPathRoot($realPath)) | # combine user-provided anchor folders with the root of the target path + ForEach-Object { Resolve-PathLexically -Path $_ -AsDirectory } | # canonicalize each anchor folder to an absolute path as a directory + Sort-Object -Descending | # sort anchor folders by descending length to prioritize the most specific one (longest path first) + Where-Object { Test-PathLexicallyContained -Path $realPath -RootFolder $_ } | # filter only those anchor folders that lexically contain the target path + Select-Object -First 1 + + if ($realPath.Length -gt $anchorPath.Length) { + # Re-inject every unverified segment of the resolved target so an embedded reparse point (e.g. link1 -> + # "link2/sub" where link2 itself escapes the root) gets its own resolve on a later iteration. + $segments.InsertRange(0, [string[]] $realPath.Substring($anchorPath.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) + $realPath = $anchorPath } - $anchorPath = Join-Path $anchorPath '' # Ensure the anchor path ends with a directory separator - - # Re-inject every unverified segment of the resolved target so an embedded reparse point (e.g. link1 -> - # "link2/sub" where link2 itself escapes the root) gets its own resolve on a later iteration. - $segments.InsertRange(0, [string[]] $targetPath.Substring($anchorPath.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) - $realPath = $anchorPath } return $realPath @@ -918,11 +957,12 @@ function Resolve-PhysicalPath { .SYNOPSIS Checks whether a path physically resolves to itself, i.e. no reparse point along the way redirects it. .DESCRIPTION -Resolves $Path (see Resolve-PhysicalPath) and compares the result to $Path itself. +Resolves $Path (see Resolve-PathPhysically) and compares the result to $Path itself. .PARAMETER Path The literal path expected to be its own final physical location. -.PARAMETER AnchorFolders -An array of anchor folders to start resolution from; reparse points within these folders are ignored (or they are otherwise known to be free of reparse points). +.PARAMETER AnchorPaths +An array of trusted anchor paths (folders, or specific files/reparse points) to start resolution from; +reparse points within these paths are ignored (or they are otherwise known to be free of reparse points). .OUTPUTS $true if $Path resolves to itself, otherwise $false. #> @@ -930,14 +970,14 @@ function Test-PathPhysicallyEqual { Param( [Parameter(Mandatory=$true)] [string] $Path, - [string[]] $AnchorFolders = @() + [string[]] $AnchorPaths = @() ) $pathComparer = GetPathStringComparer - $Path = [System.IO.Path]::GetFullPath($Path) + $Path = Resolve-PathLexically -Path $Path - $realPath = Resolve-PhysicalPath -Path $Path -AnchorFolders $AnchorFolders + $realPath = Resolve-PathPhysically -Path $Path -AnchorPaths $AnchorPaths if (-not $realPath) { return $false } @@ -953,7 +993,7 @@ Resolves file paths based on the provided source folder, destination folder, and This function takes a source folder, an optional original source folder, a destination folder, and an array of file specifications. It resolves the full paths for each specified file, considering their origin (template or custom template), type, and whether they are per-project files. The function returns an array of hashtables containing the resolved source and destination file paths. The function is used to determine which files need to be copied from the template repository to the target repository during the AL-Go update process. -Both source and destination boundary checks are lexical-only (see Test-PathLexicallyContained, resolving ".."/"." segments) - neither does a filesystem-aware symlink/junction walk here. Physical resolution (see Test-PathPhysicallyEqual/Resolve-PhysicalPath) is instead re-checked immediately before each source file is actually read and each destination file is actually written/removed, in CheckForUpdates.ps1: for the destination this is required, since the destination folder is later relocated into a new clone (see CloneIntoNewFolder) before files are written, so a filesystem-based check here would validate the wrong location anyway; for the source there is no such relocation, but checking at the point of use keeps both sides symmetric and covers the file exactly as it will be read. +Both source and destination boundary checks are lexical-only (see Test-PathLexicallyContained, resolving ".."/"." segments) - neither does a filesystem-aware symlink/junction walk here. Physical resolution (see Test-PathPhysicallyEqual/Resolve-PathPhysically) is instead re-checked immediately before each source file is actually read and each destination file is actually written/removed, in CheckForUpdates.ps1: for the destination this is required, since the destination folder is later relocated into a new clone (see CloneIntoNewFolder) before files are written, so a filesystem-based check here would validate the wrong location anyway; for the source there is no such relocation, but checking at the point of use keeps both sides symmetric and covers the file exactly as it will be read. sourceFolder: The base folder of the template used to resolve the source file paths. originalSourceFolder: The base folder of the original template used to check for original files (can be $null). This is in the case of custom templates, where if the file exists in the original template, it should be used instead of the custom template file. destinationFolder: The base folder used to construct the destination file paths. This is typically the root folder of the target repository. @@ -1001,11 +1041,8 @@ function ResolveFilePaths { return @() } - $sourceFolder = [System.IO.Path]::GetFullPath($sourceFolder) # Canonicalize the source folder to an absolute path - $sourceFolder = Join-Path $sourceFolder '' # Ensure source folder has a trailing slash for correct path resolution - - $destinationFolder = [System.IO.Path]::GetFullPath($destinationFolder) # Canonicalize the destination folder to an absolute path - $destinationFolder = Join-Path $destinationFolder '' # Ensure destination folder has a trailing slash for correct path resolution + $sourceFolder = Resolve-PathLexically -Path $sourceFolder -AsDirectory + $destinationFolder = Resolve-PathLexically -Path $destinationFolder -AsDirectory $pathComparer = GetPathStringComparer @@ -1074,14 +1111,15 @@ function ResolveFilePaths { if ($originalSourceFolder -and ($file.origin -ne 'custom template')) { $relativeSourceFile = $srcFile.Substring($sourceFolder.Length) $originalSourceFile = Join-Path $originalSourceFolder $relativeSourceFile - $originalSourceFile = [System.IO.Path]::GetFullPath($originalSourceFile) + $originalSourceFile = Resolve-PathLexically -Path $originalSourceFile if (Test-Path -LiteralPath $originalSourceFile -PathType Leaf) { if (Test-PathLexicallyContained -Path $originalSourceFile -RootFolder $originalSourceFolder) { # If the file exists in the original template folder, use that file instead $fullFilePath.originalSourceFullPath = $originalSourceFile } else { - OutputWarning "Skipping original source file '$originalSourceFile' for source file '$srcFile' as it is not under the original source folder '$originalSourceFolder'." + OutputWarning "Skipping source file '$srcFile' as the original source file '$originalSourceFile' is not under the original source folder '$originalSourceFolder'." + continue } } } @@ -1102,8 +1140,7 @@ function ResolveFilePaths { $unresolvedProjectDestinationFolder = Join-Path $destinationFolder $project $unresolvedProjectDestinationFolder = Join-Path $unresolvedProjectDestinationFolder '' # Ensure unresolved project destination folder has a trailing slash for correct path resolution - $projectDestinationFolder = [System.IO.Path]::GetFullPath($unresolvedProjectDestinationFolder) # Canonicalize the unresolved project destination folder to an absolute path - $projectDestinationFolder = Join-Path $projectDestinationFolder '' # Ensure project destination folder has a trailing slash for correct path resolution + $projectDestinationFolder = Resolve-PathLexically -Path $unresolvedProjectDestinationFolder -AsDirectory # Check if the unresolved project destination folder resolves to the same absolute path (e.g. catches ".." and "." segments) if ($unresolvedProjectDestinationFolder -ne $projectDestinationFolder) { @@ -1118,8 +1155,7 @@ function ResolveFilePaths { } $fileDestinationFolder = Join-Path $projectDestinationFolder $file.destinationFolder - $fileDestinationFolder = [System.IO.Path]::GetFullPath($fileDestinationFolder) # Canonicalize the file destination folder to an absolute path - $fileDestinationFolder = Join-Path $fileDestinationFolder '' # Ensure file destination folder has a trailing slash for correct path resolution + $fileDestinationFolder = Resolve-PathLexically -Path $fileDestinationFolder -AsDirectory # Check if the destination folder is under the project destination folder (lexical, resolves ".."/".") if (-not (Test-PathLexicallyContained -Path $fileDestinationFolder -RootFolder $projectDestinationFolder)) { @@ -1129,7 +1165,7 @@ function ResolveFilePaths { $fullProjectFilePath = $fullFilePath.Clone() $fullProjectFilePath.destinationFullPath = Join-Path $fileDestinationFolder $destinationName - $fullProjectFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullProjectFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path + $fullProjectFilePath.destinationFullPath = Resolve-PathLexically -Path $fullProjectFilePath.destinationFullPath # Check if the destination file is under the file destination folder (lexical, resolves ".."/".") if (-not (Test-PathLexicallyContained -Path $fullProjectFilePath.destinationFullPath -RootFolder $fileDestinationFolder)) { @@ -1150,8 +1186,7 @@ function ResolveFilePaths { # Destination full path is the destination base folder + destinationFolder + destinationName $fileDestinationFolder = Join-Path $destinationFolder $file.destinationFolder - $fileDestinationFolder = [System.IO.Path]::GetFullPath($fileDestinationFolder) # Canonicalize the file destination folder to an absolute path - $fileDestinationFolder = Join-Path $fileDestinationFolder '' # Ensure file destination folder has a trailing slash for correct path resolution + $fileDestinationFolder = Resolve-PathLexically -Path $fileDestinationFolder -AsDirectory # Check if the destination folder is under the base destination folder (lexical, resolves ".."/".") if (-not (Test-PathLexicallyContained -Path $fileDestinationFolder -RootFolder $destinationFolder)) { @@ -1160,7 +1195,7 @@ function ResolveFilePaths { } $fullFilePath.destinationFullPath = Join-Path $fileDestinationFolder $destinationName - $fullFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path + $fullFilePath.destinationFullPath = Resolve-PathLexically -Path $fullFilePath.destinationFullPath # Check if the destination file is under the file destination folder (lexical, resolves ".."/".") if (-not (Test-PathLexicallyContained -Path $fullFilePath.destinationFullPath -RootFolder $fileDestinationFolder)) { diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index 99ad06b9fc..86b148a082 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -1,4 +1,4 @@ -Param( +Param( [Parameter(HelpMessage = "The GitHub actor running the action", Mandatory = $false)] [string] $actor, [Parameter(HelpMessage = "Base64 encoded GhTokenWorkflow secret", Mandatory = $false)] @@ -155,13 +155,13 @@ foreach($fileToInclude in $filesToInclude) { } # Skip files that do not physically resolve to themselves within the template folders - if (-not (Test-PathPhysicallyEqual -Path $srcPath -AnchorFolders $templateFolders)) { + if (-not (Test-PathPhysicallyEqual -Path $srcPath -AnchorPaths $templateFolders)) { OutputWarning "Skipping file '$srcPath': source does not physically resolve to itself within the template folder(s). This may indicate a symlink/junction redirect." continue } if ($originalSrcPath -ne $srcPath) { # Skip files with original files that do not physically resolve to themselves within the template folders - if (-not (Test-PathPhysicallyEqual -Path $originalSrcPath -AnchorFolders $templateFolders)) { + if (-not (Test-PathPhysicallyEqual -Path $originalSrcPath -AnchorPaths $templateFolders)) { OutputWarning "Skipping file '$srcPath': original source '$originalSrcPath' does not physically resolve to itself within the template folder(s). This may indicate a symlink/junction redirect." continue } @@ -283,7 +283,7 @@ else { $releaseNotes = "" $updateFiles | ForEach-Object { # Skip files that do not physically resolve to themselves within the destination root folder - if (-not (Test-PathPhysicallyEqual -Path (Join-Path $dstRoot $_.DstFile) -AnchorFolders @($dstRoot))) { + if (-not (Test-PathPhysicallyEqual -Path (Join-Path $dstRoot $_.DstFile) -AnchorPaths @($dstRoot))) { OutputWarning "Skipping update of '$($_.DstFile)': destination does not physically resolve to itself. This may indicate a symlink/junction redirect." return } @@ -316,7 +316,7 @@ else { } $removeFiles | ForEach-Object { # Skip files that do not physically resolve to themselves within the destination root folder - if (-not (Test-PathPhysicallyEqual -Path (Join-Path $dstRoot $_) -AnchorFolders @($dstRoot))) { + if (-not (Test-PathPhysicallyEqual -Path (Join-Path $dstRoot $_) -AnchorPaths @($dstRoot))) { OutputWarning "Skipping removal of '$_': destination does not physically resolve to itself. This may indicate a symlink/junction redirect." return } diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index c28ea73886..5d897e2aa4 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -111,6 +111,7 @@ Describe "CheckForUpdates Action: physical path guards" { $testFilesToInclude = @( @{ sourceFullPath = $legitFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "legit.txt") } @{ sourceFullPath = $redirectedFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirected.txt") } + @{ sourceFullPath = $legitFile; originalSourceFullPath = $redirectedFile; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirected.txt") } ) Mock DownloadAndImportBcContainerHelper {} @@ -127,7 +128,7 @@ Describe "CheckForUpdates Action: physical path guards" { . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update N - Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*redirected.txt*does not physically resolve*" } + Should -Invoke OutputWarning -Exactly 2 -ParameterFilter { $message -like "Skipping file*redirected.txt*does not physically resolve*" } $updateFiles.Count | Should -Be 1 $updateFiles[0].DstFile | Should -Be "legit.txt" } @@ -196,8 +197,8 @@ Describe "CheckForUpdates Action: physical path guards" { . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update Y -updateBranch "update-al-go-system-files" -token $fakeToken -directCommit $true -actor "test-actor" $updateFiles.Count | Should -Be 2 - Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*Skipping update*trap.txt*" } - Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*Skipping removal*oldfile.txt*" } + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping update*trap.txt*" } + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping removal*oldfile.txt*" } # The legitimate update was actually written to the (mocked) clone root; the trapped one was not Test-Path -Path (Join-Path $testCloneRoot "legit.txt") -PathType Leaf | Should -Be $true @@ -1761,7 +1762,7 @@ Describe "ResolveFilePaths" { $fullFilePaths[4].type | Should -Be "markdown" } - It 'ResolveFilePaths skips files outside the source folder' { + It 'ResolveFilePaths skips source files lexically escaping outside the source folder' { $externalFolder = Join-Path $PSScriptRoot "external" $externalFile = Join-Path $externalFolder "outside.txt" $destinationFolder = Join-Path $rootFolder 'destinationFolder' @@ -1784,7 +1785,7 @@ Describe "ResolveFilePaths" { } } - It 'ResolveFilePaths skips files in folder whose name starts with source folder name' { + It 'ResolveFilePaths skips source files lexically escaping into a folder whose name starts with source folder name' { $externalFolder = "${sourceFolder}-external" $externalFile = Join-Path $externalFolder "outside.txt" $destinationFolder = Join-Path $rootFolder 'destinationFolder' @@ -1807,8 +1808,31 @@ Describe "ResolveFilePaths" { } } - It 'ResolveFilePaths skips files in a source folder that differs only by case on Linux' -Skip:(-not $script:isLinuxPlatform) { - $externalFolder = Join-Path $rootFolder 'sourcefolder' + It 'ResolveFilePaths skips source files when its original source file lexically escapes the original source folder' { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $escapedOriginalSourceFile = Resolve-PathLexically -Path (Join-Path $originalSourceFolder "folder/File1.txt") + + # Test-PathLexicallyContained already guarantees this can't happen through legitimate inputs (both sides are + # always resolved consistently), so force just this one call to fail to exercise the defensive "else" branch. + $realTestPathLexicallyContained = (Get-Item function:Test-PathLexicallyContained).ScriptBlock + Mock Test-PathLexicallyContained { + if ($Path -eq $escapedOriginalSourceFile -and $RootFolder -eq $originalSourceFolder) { + return $false + } + & $realTestPathLexicallyContained -Path $Path -RootFolder $RootFolder + } + Mock OutputWarning {} + + $files = @(@{ "sourceFolder" = "folder"; "filter" = "File1.txt" }) + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -originalSourceFolder $originalSourceFolder) + + # The file must be skipped entirely, not merely left with originalSourceFullPath = $null + $fullFilePaths | Where-Object { $_.sourceFullPath -eq (Join-Path $sourceFolder "folder/File1.txt") } | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*is not under the original source folder*" } + } + + It 'ResolveFilePaths skips source files lexically escaping to folder that differs only by case on Linux' -Skip:(-not $script:isLinuxPlatform) { + $externalFolder = Join-Path $rootFolder 'SOURCEFOLDER' $externalFile = Join-Path $externalFolder 'outside.txt' $destinationFolder = Join-Path $rootFolder 'destinationFolder' @@ -1817,7 +1841,7 @@ Describe "ResolveFilePaths" { Set-Content -Path $externalFile -Value 'outside' $files = @( - @{ 'sourceFolder' = '../sourcefolder'; 'filter' = '*.txt' } + @{ 'sourceFolder' = '../SOURCEFOLDER'; 'filter' = '*.txt' } ) $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) @@ -1829,10 +1853,10 @@ Describe "ResolveFilePaths" { } } - It 'ResolveFilePaths skips destinations in a folder that differs only by case on Linux' -Skip:(-not $script:isLinuxPlatform) { + It 'ResolveFilePaths skips destinations lexically escaping to folder that differs only by case on Linux' -Skip:(-not $script:isLinuxPlatform) { $destinationFolder = Join-Path $rootFolder 'destinationFolder' $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = '../casefolder/outside.txt' } + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'folder'; 'destinationName' = '../FOLDER/outside.txt' } ) Mock OutputWarning {} @@ -1842,35 +1866,19 @@ Describe "ResolveFilePaths" { Should -Invoke OutputWarning -Times 1 } - It 'ResolveFilePaths skips per-project destinations in a folder that differs only by case on Linux' -Skip:(-not $script:isLinuxPlatform) { + It 'ResolveFilePaths skips per-project destinations lexically escaping to folder that differs only by case on Linux' -Skip:(-not $script:isLinuxPlatform) { $destinationFolder = Join-Path $rootFolder 'destinationFolder' $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = ''; 'destinationName' = '../caseproject/outside.txt'; 'perProject' = $true } + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = ''; 'destinationName' = '../PROJECT/outside.txt'; 'perProject' = $true } ) Mock OutputWarning {} - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('CaseProject')) + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('project')) $fullFilePaths | Should -BeNullOrEmpty Should -Invoke OutputWarning -Times 1 } - It 'ResolveFilePaths skips source files reachable only via ".." traversal outside the source folder' { - $destinationFolder = Join-Path $rootFolder 'destinationFolderEscapeTest' - try { - $files = @( - @{ 'sourceFolder' = '../originalSourceFolder/folder'; 'filter' = '*.txt' } - ) - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) - - $fullFilePaths | Should -BeNullOrEmpty - } - finally { - Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue - } - } - It 'ResolveFilePaths returns empty when no files match filter' { $destinationFolder = "destinationFolder" $destinationFolder = Join-Path $rootFolder $destinationFolder @@ -2282,6 +2290,56 @@ Describe "ResolveFilePaths" { } } +Describe "Resolve-PathLexically" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + + $rootFolder = Join-Path $PSScriptRoot "resolvePathLexicallyTests" + New-Item -Path $rootFolder -ItemType Directory -Force | Out-Null + } + + AfterAll { + Remove-Item -Path $rootFolder -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'Resolve-PathLexically returns an already-rooted path unchanged' { + $path = Join-Path $rootFolder "folder/file.txt" + Resolve-PathLexically -Path $path | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + + It 'Resolve-PathLexically resolves ".." and "." segments in a rooted path' { + $path = Join-Path $rootFolder "folder/../folder2/./file.txt" + Resolve-PathLexically -Path $path | Should -Be (Join-Path $rootFolder "folder2/file.txt") + } + + It 'Resolve-PathLexically resolves a relative path against the current location' { + Push-Location -Path $rootFolder + try { + Resolve-PathLexically -Path "sub/file.txt" | Should -Be (Join-Path $rootFolder "sub/file.txt") + } + finally { + Pop-Location + } + } + + It 'Resolve-PathLexically with -AsDirectory ensures a trailing directory separator' { + $path = Join-Path $rootFolder "folder" + Resolve-PathLexically -Path $path -AsDirectory | Should -Be (Join-Path ([System.IO.Path]::GetFullPath($path)) '') + } + + It 'Resolve-PathLexically with -AsDirectory does not duplicate an existing trailing directory separator' { + $path = Join-Path (Join-Path $rootFolder "folder") '' + Resolve-PathLexically -Path $path -AsDirectory | Should -Be $path + } + + It 'Resolve-PathLexically without -AsDirectory does not add a trailing directory separator' { + $path = Join-Path $rootFolder "folder" + Resolve-PathLexically -Path $path | Should -Be ([System.IO.Path]::GetFullPath($path)) + } +} + Describe "Test-PathLexicallyContained" { BeforeAll { $actionName = "CheckForUpdates" @@ -2323,6 +2381,16 @@ Describe "Test-PathLexicallyContained" { Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true } + It 'Test-PathLexicallyContained returns true when Path equals RootFolder exactly' { + Test-PathLexicallyContained -Path $rootFolder -RootFolder $rootFolder | Should -Be $true + } + + It 'Test-PathLexicallyContained returns true when Path equals RootFolder with a trailing separator on either side' { + $rootFolderWithSlash = Join-Path $rootFolder '' + Test-PathLexicallyContained -Path $rootFolder -RootFolder $rootFolderWithSlash | Should -Be $true + Test-PathLexicallyContained -Path $rootFolderWithSlash -RootFolder $rootFolder | Should -Be $true + } + It 'Test-PathLexicallyContained is case-insensitive on Windows' -Skip:(-not $script:isWindowsPlatform) { $path = Join-Path $rootFolder "FOLDER/file.txt" Test-PathLexicallyContained -Path $path -RootFolder (Join-Path $rootFolder "folder") | Should -Be $true @@ -2334,7 +2402,7 @@ Describe "Test-PathLexicallyContained" { } } -Describe "Resolve-PhysicalPath" { +Describe "Resolve-PathPhysically" { BeforeAll { $actionName = "CheckForUpdates" $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve @@ -2350,12 +2418,36 @@ Describe "Resolve-PhysicalPath" { Remove-Item -Path $rootFolder, $externalFolder -Recurse -Force -ErrorAction SilentlyContinue } - It 'Resolve-PhysicalPath returns the canonicalized path when there are no reparse points' { + It 'Resolve-PathPhysically returns the canonicalized path when there are no reparse points' { $path = Join-Path $rootFolder "folder/file.txt" - Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be ([System.IO.Path]::GetFullPath($path)) + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + + It 'Resolve-PathPhysically returns a folder anchor unchanged when Path equals the anchor exactly' { + $folder = Join-Path $rootFolder "exactFolderAnchor" + New-Item -Path $folder -ItemType Directory -Force | Out-Null + Resolve-PathPhysically -Path $folder -AnchorPaths @($folder) | Should -Be ([System.IO.Path]::GetFullPath($folder)) + } + + It 'Resolve-PathPhysically returns a file anchor unchanged when Path equals the anchor exactly' { + $file = Join-Path $rootFolder "exactFileAnchor.txt" + Resolve-PathPhysically -Path $file -AnchorPaths @($file) | Should -Be ([System.IO.Path]::GetFullPath($file)) + } + + It 'Resolve-PathPhysically resolves a relative Path against the current location' { + $subFolder = Join-Path $rootFolder "relativePathInput" + New-Item -Path $subFolder -ItemType Directory -Force | Out-Null + Push-Location -Path $subFolder + try { + Resolve-PathPhysically -Path "file.txt" | Should -Be (Join-Path $subFolder "file.txt") + } + finally { + Pop-Location + Remove-Item -Path $subFolder -Recurse -Force -ErrorAction SilentlyContinue + } } - It 'Resolve-PhysicalPath resolves a single symlink to its real target' -Skip:(-not $script:hasSymlinkCapability) { + It 'Resolve-PathPhysically resolves a single symlink to its real target' -Skip:(-not $script:hasSymlinkCapability) { $realTargetFolder = Join-Path $rootFolder "singleSymRealTarget" $linkedFolder = Join-Path $rootFolder "singleSymLink" $path = Join-Path $linkedFolder "file.txt" @@ -2363,7 +2455,7 @@ Describe "Resolve-PhysicalPath" { New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null New-Item -ItemType SymbolicLink -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null - $resolved = Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) + $resolved = Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) $resolved | Should -Be (Join-Path $realTargetFolder "file.txt") $resolved | Should -Not -Be ([System.IO.Path]::GetFullPath($path)) @@ -2374,7 +2466,7 @@ Describe "Resolve-PhysicalPath" { } } - It 'Resolve-PhysicalPath resolves a single junction to its real target' -Skip:(-not $script:isWindowsPlatform) { + It 'Resolve-PathPhysically resolves a single junction to its real target' -Skip:(-not $script:isWindowsPlatform) { $realTargetFolder = Join-Path $rootFolder "singleJctRealTarget" $linkedFolder = Join-Path $rootFolder "singleJctLink" $path = Join-Path $linkedFolder "file.txt" @@ -2382,7 +2474,7 @@ Describe "Resolve-PhysicalPath" { New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null New-Item -ItemType Junction -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null - $resolved = Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) + $resolved = Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) $resolved | Should -Be (Join-Path $realTargetFolder "file.txt") $resolved | Should -Not -Be ([System.IO.Path]::GetFullPath($path)) @@ -2393,7 +2485,7 @@ Describe "Resolve-PhysicalPath" { } } - It 'Resolve-PhysicalPath resolves a chain of two symlinks (link1 -> link2 -> real folder) to its real target' -Skip:(-not $script:hasSymlinkCapability) { + It 'Resolve-PathPhysically resolves a chain of two symlinks (link1 -> link2 -> real folder) to its real target' -Skip:(-not $script:hasSymlinkCapability) { $realFolder = Join-Path $rootFolder "chainSymRealTarget" $link2 = Join-Path $rootFolder "chainSymLink2" $link1 = Join-Path $rootFolder "chainSymLink1" @@ -2403,7 +2495,7 @@ Describe "Resolve-PhysicalPath" { New-Item -ItemType SymbolicLink -Path $link2 -Target $realFolder -Force | Out-Null New-Item -ItemType SymbolicLink -Path $link1 -Target $link2 -Force | Out-Null - Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be (Join-Path $realFolder "file.txt") + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be (Join-Path $realFolder "file.txt") } finally { Remove-Item -Path $link1 -Recurse -Force -ErrorAction SilentlyContinue @@ -2412,7 +2504,7 @@ Describe "Resolve-PhysicalPath" { } } - It 'Resolve-PhysicalPath resolves a chain of two junctions (link1 -> link2 -> real folder) to its real target' -Skip:(-not $script:isWindowsPlatform) { + It 'Resolve-PathPhysically resolves a chain of two junctions (link1 -> link2 -> real folder) to its real target' -Skip:(-not $script:isWindowsPlatform) { $realFolder = Join-Path $rootFolder "chainJctRealTarget" $link2 = Join-Path $rootFolder "chainJctLink2" $link1 = Join-Path $rootFolder "chainJctLink1" @@ -2422,7 +2514,7 @@ Describe "Resolve-PhysicalPath" { New-Item -ItemType Junction -Path $link2 -Target $realFolder -Force | Out-Null New-Item -ItemType Junction -Path $link1 -Target $link2 -Force | Out-Null - Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be (Join-Path $realFolder "file.txt") + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be (Join-Path $realFolder "file.txt") } finally { Remove-Item -Path $link1 -Recurse -Force -ErrorAction SilentlyContinue @@ -2431,7 +2523,7 @@ Describe "Resolve-PhysicalPath" { } } - It 'Resolve-PhysicalPath resolves a dangling symlink to its real target without warning' -Skip:(-not $script:hasSymlinkCapability) { + It 'Resolve-PathPhysically resolves a dangling symlink to its real target without warning' -Skip:(-not $script:hasSymlinkCapability) { $danglingTarget = Join-Path $rootFolder "danglingSymTarget" $linkPath = Join-Path $rootFolder "danglingSymLink" try { @@ -2440,7 +2532,7 @@ Describe "Resolve-PhysicalPath" { Remove-Item -Path $danglingTarget -Force Mock OutputWarning {} - Resolve-PhysicalPath -Path $linkPath -AnchorFolders @($rootFolder) | Should -Be ([System.IO.Path]::GetFullPath($danglingTarget)) + Resolve-PathPhysically -Path $linkPath -AnchorPaths @($rootFolder) | Should -Be ([System.IO.Path]::GetFullPath($danglingTarget)) Should -Invoke OutputWarning -Times 0 } finally { @@ -2449,7 +2541,7 @@ Describe "Resolve-PhysicalPath" { } } - It 'Resolve-PhysicalPath resolves a dangling junction to its real target without warning' -Skip:(-not $script:isWindowsPlatform) { + It 'Resolve-PathPhysically resolves a dangling junction to its real target without warning' -Skip:(-not $script:isWindowsPlatform) { $danglingTarget = Join-Path $rootFolder "danglingJctTarget" $linkPath = Join-Path $rootFolder "danglingJctLink" try { @@ -2458,7 +2550,7 @@ Describe "Resolve-PhysicalPath" { Remove-Item -Path $danglingTarget -Force Mock OutputWarning {} - Resolve-PhysicalPath -Path $linkPath -AnchorFolders @($rootFolder) | Should -Be ([System.IO.Path]::GetFullPath($danglingTarget)) + Resolve-PathPhysically -Path $linkPath -AnchorPaths @($rootFolder) | Should -Be ([System.IO.Path]::GetFullPath($danglingTarget)) Should -Invoke OutputWarning -Times 0 } finally { @@ -2467,7 +2559,7 @@ Describe "Resolve-PhysicalPath" { } } - It 'Resolve-PhysicalPath returns $null and warns when a symlink chain cycles back on itself' -Skip:(-not $script:hasSymlinkCapability) { + It 'Resolve-PathPhysically returns $null and warns when a symlink chain cycles back on itself' -Skip:(-not $script:hasSymlinkCapability) { $linkA = Join-Path $rootFolder "cyclicLinkA" $linkB = Join-Path $rootFolder "cyclicLinkB" try { @@ -2477,7 +2569,7 @@ Describe "Resolve-PhysicalPath" { New-Item -ItemType SymbolicLink -Path $linkA -Target $linkB -Force | Out-Null Mock OutputWarning {} - Resolve-PhysicalPath -Path $linkA -AnchorFolders @($rootFolder) | Should -Be $null + Resolve-PathPhysically -Path $linkA -AnchorPaths @($rootFolder) | Should -Be $null Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*exceeded*hops*" } } finally { @@ -2486,7 +2578,7 @@ Describe "Resolve-PhysicalPath" { } } - It 'Resolve-PhysicalPath returns $null and warns when a junction chain cycles back on itself' -Skip:(-not $script:isWindowsPlatform) { + It 'Resolve-PathPhysically returns $null and warns when a junction chain cycles back on itself' -Skip:(-not $script:isWindowsPlatform) { $nodeA = Join-Path $rootFolder "cyclicJctNodeA" $nodeB = Join-Path $rootFolder "cyclicJctNodeB" try { @@ -2496,7 +2588,7 @@ Describe "Resolve-PhysicalPath" { New-Item -ItemType Junction -Path $nodeA -Target $nodeB -Force | Out-Null Mock OutputWarning {} - Resolve-PhysicalPath -Path $nodeA -AnchorFolders @($rootFolder) | Should -Be $null + Resolve-PathPhysically -Path $nodeA -AnchorPaths @($rootFolder) | Should -Be $null Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*exceeded*hops*" } } finally { @@ -2505,7 +2597,7 @@ Describe "Resolve-PhysicalPath" { } } - It 'Resolve-PhysicalPath resolves a symlink whose target path has a parent symlink (link1 -> link2/subdir/file.txt)' -Skip:(-not $script:hasSymlinkCapability) { + It 'Resolve-PathPhysically resolves a symlink whose target path has a parent symlink (link1 -> link2/subdir/file.txt)' -Skip:(-not $script:hasSymlinkCapability) { $realFolder = Join-Path $rootFolder "nestedSymRealTarget" $parentLink = Join-Path $rootFolder "nestedSymParentLink" $outerLink = Join-Path $rootFolder "nestedSymOuterLink" @@ -2515,7 +2607,7 @@ Describe "Resolve-PhysicalPath" { New-Item -ItemType SymbolicLink -Path $parentLink -Target $realFolder -Force | Out-Null New-Item -ItemType SymbolicLink -Path $outerLink -Target (Join-Path $parentLink "subdir") -Force | Out-Null - Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be (Join-Path $realFolder "subdir/file.txt") + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be (Join-Path $realFolder "subdir/file.txt") } finally { Remove-Item -Path $outerLink -Recurse -Force -ErrorAction SilentlyContinue @@ -2524,7 +2616,7 @@ Describe "Resolve-PhysicalPath" { } } - It 'Resolve-PhysicalPath resolves a junction whose target path has a parent junction (link1 -> link2/subdir/file.txt)' -Skip:(-not $script:isWindowsPlatform) { + It 'Resolve-PathPhysically resolves a junction whose target path has a parent junction (link1 -> link2/subdir/file.txt)' -Skip:(-not $script:isWindowsPlatform) { $realFolder = Join-Path $rootFolder "nestedJctRealTarget" $parentLink = Join-Path $rootFolder "nestedJctParentLink" $outerLink = Join-Path $rootFolder "nestedJctOuterLink" @@ -2534,7 +2626,7 @@ Describe "Resolve-PhysicalPath" { New-Item -ItemType Junction -Path $parentLink -Target $realFolder -Force | Out-Null New-Item -ItemType Junction -Path $outerLink -Target (Join-Path $parentLink "subdir") -Force | Out-Null - Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be (Join-Path $realFolder "subdir/file.txt") + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be (Join-Path $realFolder "subdir/file.txt") } finally { Remove-Item -Path $outerLink -Recurse -Force -ErrorAction SilentlyContinue @@ -2543,47 +2635,91 @@ Describe "Resolve-PhysicalPath" { } } - It 'Resolve-PhysicalPath trusts a symlink when its own path is passed as an anchor folder, skipping resolution' -Skip:(-not $script:hasSymlinkCapability) { + It 'Resolve-PathPhysically trusts a symlink when its own path is passed as an anchor folder, skipping resolution' -Skip:(-not $script:hasSymlinkCapability) { + $externalTargetFolder = Join-Path $externalFolder "anchorTrustSymTarget" + $trustedLink = Join-Path $rootFolder "anchorTrustSymLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + # Without the link in AnchorPaths, it is followed to its real (external) target + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be (Join-Path $externalTargetFolder "file.txt") + + # When the link's own path is passed as an anchor, it is trusted and not followed + Resolve-PathPhysically -Path $path -AnchorPaths @($trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically trusts a junction when its own path is passed as an anchor folder, skipping resolution' -Skip:(-not $script:isWindowsPlatform) { + $externalTargetFolder = Join-Path $externalFolder "anchorTrustJctTarget" + $trustedLink = Join-Path $rootFolder "anchorTrustJctLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + # Without the link in AnchorPaths, it is followed to its real (external) target + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be (Join-Path $externalTargetFolder "file.txt") + + # When the link's own path is passed as an anchor, it is trusted and not followed + Resolve-PathPhysically -Path $path -AnchorPaths @($trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically trusts a symlink when its own path is passed as a relative anchor folder, skipping resolution' -Skip:(-not $script:hasSymlinkCapability) { $externalTargetFolder = Join-Path $externalFolder "anchorTrustSymTarget" $trustedLink = Join-Path $rootFolder "anchorTrustSymLink" $path = Join-Path $trustedLink "file.txt" try { + Push-Location $rootFolder New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null New-Item -ItemType SymbolicLink -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null - # Without the link in AnchorFolders, it is followed to its real (external) target - Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be (Join-Path $externalTargetFolder "file.txt") + # Without the link in AnchorPaths, it is followed to its real (external) target + Resolve-PathPhysically -Path $path -AnchorPaths @(".") | Should -Be (Join-Path $externalTargetFolder "file.txt") # When the link's own path is passed as an anchor, it is trusted and not followed - Resolve-PhysicalPath -Path $path -AnchorFolders @($trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + Resolve-PathPhysically -Path $path -AnchorPaths @(Split-Path $trustedLink -Leaf) | Should -Be ([System.IO.Path]::GetFullPath($path)) } finally { + Pop-Location Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue } } - It 'Resolve-PhysicalPath trusts a junction when its own path is passed as an anchor folder, skipping resolution' -Skip:(-not $script:isWindowsPlatform) { + It 'Resolve-PathPhysically trusts a junction when its own path is passed as a relative anchor folder, skipping resolution' -Skip:(-not $script:isWindowsPlatform) { $externalTargetFolder = Join-Path $externalFolder "anchorTrustJctTarget" $trustedLink = Join-Path $rootFolder "anchorTrustJctLink" $path = Join-Path $trustedLink "file.txt" try { + Push-Location $rootFolder New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null New-Item -ItemType Junction -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null - # Without the link in AnchorFolders, it is followed to its real (external) target - Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder) | Should -Be (Join-Path $externalTargetFolder "file.txt") + # Without the link in AnchorPaths, it is followed to its real (external) target + Resolve-PathPhysically -Path $path -AnchorPaths @(".") | Should -Be (Join-Path $externalTargetFolder "file.txt") # When the link's own path is passed as an anchor, it is trusted and not followed - Resolve-PhysicalPath -Path $path -AnchorFolders @($trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + Resolve-PathPhysically -Path $path -AnchorPaths @(Split-Path $trustedLink -Leaf) | Should -Be ([System.IO.Path]::GetFullPath($path)) } finally { + Pop-Location Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue } } - It 'Resolve-PhysicalPath picks the most specific of multiple anchors, trusting a symlink sitting at the deeper anchor' -Skip:(-not $script:hasSymlinkCapability) { + It 'Resolve-PathPhysically picks the most specific of multiple anchors, trusting a symlink sitting at the deeper anchor' -Skip:(-not $script:hasSymlinkCapability) { $externalTargetFolder = Join-Path $externalFolder "multiAnchorSymTarget" $trustedLink = Join-Path $rootFolder "multiAnchorSymLink" $path = Join-Path $trustedLink "file.txt" @@ -2593,7 +2729,7 @@ Describe "Resolve-PhysicalPath" { # A broader anchor (rootFolder) alone would not bypass resolution, but the more specific # anchor (the link itself) is selected and trusted, even when both are supplied together - Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder, $trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder, $trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) } finally { Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue @@ -2601,7 +2737,7 @@ Describe "Resolve-PhysicalPath" { } } - It 'Resolve-PhysicalPath picks the most specific of multiple anchors, trusting a junction sitting at the deeper anchor' -Skip:(-not $script:isWindowsPlatform) { + It 'Resolve-PathPhysically picks the most specific of multiple anchors, trusting a junction sitting at the deeper anchor' -Skip:(-not $script:isWindowsPlatform) { $externalTargetFolder = Join-Path $externalFolder "multiAnchorJctTarget" $trustedLink = Join-Path $rootFolder "multiAnchorJctLink" $path = Join-Path $trustedLink "file.txt" @@ -2611,7 +2747,7 @@ Describe "Resolve-PhysicalPath" { # A broader anchor (rootFolder) alone would not bypass resolution, but the more specific # anchor (the link itself) is selected and trusted, even when both are supplied together - Resolve-PhysicalPath -Path $path -AnchorFolders @($rootFolder, $trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder, $trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) } finally { Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue @@ -2636,27 +2772,27 @@ Describe "Test-PathPhysicallyEqual" { Remove-Item -Path $rootFolder, $externalFolder -Recurse -Force -ErrorAction SilentlyContinue } - It 'Test-PathPhysicallyEqual delegates to Resolve-PhysicalPath and compares its result against the canonicalized path' { + It 'Test-PathPhysicallyEqual delegates to Resolve-PathPhysically and compares its result against the canonicalized path' { $path = Join-Path $rootFolder "delegationCheck/file.txt" - Mock Resolve-PhysicalPath { return $Path } - Test-PathPhysicallyEqual -Path $path -AnchorFolders @($rootFolder) | Should -Be $true - Should -Invoke Resolve-PhysicalPath -Times 1 -ParameterFilter { - $Path -eq ([System.IO.Path]::GetFullPath($path)) -and (Compare-Object $AnchorFolders @($rootFolder) | Measure-Object).Count -eq 0 + Mock Resolve-PathPhysically { return $Path } + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $true + Should -Invoke Resolve-PathPhysically -Times 1 -ParameterFilter { + $Path -eq ([System.IO.Path]::GetFullPath($path)) -and (Compare-Object $AnchorPaths @($rootFolder) | Measure-Object).Count -eq 0 } - Mock Resolve-PhysicalPath { return (Join-Path $rootFolder "somewhereElse/file.txt") } - Test-PathPhysicallyEqual -Path $path -AnchorFolders @($rootFolder) | Should -Be $false + Mock Resolve-PathPhysically { return (Join-Path $rootFolder "somewhereElse/file.txt") } + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $false } It 'Test-PathPhysicallyEqual returns true when there are no reparse points' { $path = Join-Path $rootFolder "folder/file.txt" - Test-PathPhysicallyEqual -Path $path -AnchorFolders @($rootFolder) | Should -Be $true + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $true } It 'Test-PathPhysicallyEqual returns true when the path does not exist yet' { $path = Join-Path $rootFolder "doesNotExist/file.txt" - Test-PathPhysicallyEqual -Path $path -AnchorFolders @($rootFolder) | Should -Be $true + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $true } It 'Test-PathPhysicallyEqual returns false when a symlink redirects to a different real location' -Skip:(-not $script:hasSymlinkCapability) { @@ -2667,7 +2803,7 @@ Describe "Test-PathPhysicallyEqual" { New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null New-Item -ItemType SymbolicLink -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null - Test-PathPhysicallyEqual -Path $path -AnchorFolders @($rootFolder) | Should -Be $false + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $false } finally { Remove-Item -Path $linkedFolder -Recurse -Force -ErrorAction SilentlyContinue @@ -2683,7 +2819,7 @@ Describe "Test-PathPhysicallyEqual" { New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null New-Item -ItemType Junction -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null - Test-PathPhysicallyEqual -Path $path -AnchorFolders @($rootFolder) | Should -Be $false + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $false } finally { Remove-Item -Path $linkedFolder -Recurse -Force -ErrorAction SilentlyContinue @@ -2699,7 +2835,7 @@ Describe "Test-PathPhysicallyEqual" { New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null New-Item -ItemType SymbolicLink -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null - Test-PathPhysicallyEqual -Path $path -AnchorFolders @($trustedLink) | Should -Be $true + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($trustedLink) | Should -Be $true } finally { Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue @@ -2715,7 +2851,7 @@ Describe "Test-PathPhysicallyEqual" { New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null New-Item -ItemType Junction -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null - Test-PathPhysicallyEqual -Path $path -AnchorFolders @($trustedLink) | Should -Be $true + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($trustedLink) | Should -Be $true } finally { Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue From 65911f2df13d924b3a2f26cf2f924139111ca485 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Fri, 25 Sep 2026 00:50:23 +0200 Subject: [PATCH 27/34] add tests for template settings snapshot --- .../CheckForUpdates.HelperFunctions.ps1 | 14 +- Tests/CheckForUpdates.Action.Test.ps1 | 126 ++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index de3d85b759..9f6578f112 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -1271,6 +1271,9 @@ function GetDefaultFilesToExclude { Temporarily refreshes the custom template repository settings snapshot, reads the merged settings, and restores the snapshot to its original state. This allows the current template settings to affect the current run while preserving the workspace state for the normal update comparison. + Both copy endpoints (the snapshot file under baseFolder and the settings file under templateFolder) are + validated to physically resolve to themselves before either is read or written; the function throws if a + symlink/junction anywhere along either path would redirect the backup, copy or restore to a different physical location .PARAMETER baseFolder The base folder of the repository whose settings are read. .PARAMETER templateFolder @@ -1285,8 +1288,17 @@ function ReadSettingsWithCurrentCustomTemplateRepoSettings { ) $templateFolderRepoSettingsPath = Join-Path $templateFolder $RepoSettingsFile - $baseFolderTemplateSettingsPath = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + + # Validate both copy endpoints before touching them (even if the leaf file doesn't exist yet): a symlink/junction + # anywhere along either path can make it resolve to a different physical location than its literal path + if (-not (Test-PathPhysicallyEqual -Path $baseFolderTemplateSettingsPath -AnchorPaths @($baseFolder))) { + throw "Cannot read settings: '$baseFolderTemplateSettingsPath' does not physically resolve to itself. This may indicate a symlink/junction redirect." + } + if (-not (Test-PathPhysicallyEqual -Path $templateFolderRepoSettingsPath -AnchorPaths @($templateFolder))) { + throw "Cannot read settings: '$templateFolderRepoSettingsPath' does not physically resolve to itself. This may indicate a symlink/junction redirect." + } + $baseFolderTemplateSettingsBackupPath = $null if (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 5d897e2aa4..6fe17cef89 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -3785,6 +3785,132 @@ Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { Get-ContentLF -Path $snapshotFile | Should -Be $snapshotContent } + + It 'Throws and leaves the external target untouched when the base folder ".github" is a symlink redirecting elsewhere' -Skip:(-not $script:hasSymlinkCapability) { + $templateFolder = Join-Path $TestDrive "templateForBaseGithubLinkEscape" + $baseFolder = Join-Path $TestDrive "baseWithGithubLinkEscape" + $externalGithubFolder = Join-Path $TestDrive "externalGithubForBaseLinkEscape" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path $baseFolder -Force | Out-Null + New-Item -ItemType Directory -Path $externalGithubFolder -Force | Out-Null + + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + Set-Content -LiteralPath $templateSettingsFile -Value '{"customALGoFiles":{"filesToInclude":[{"filter":"current.txt"}]}}' -Encoding UTF8 + + $externalSnapshotFile = Join-Path $externalGithubFolder $CustomTemplateRepoSettingsFileName + $externalSnapshotContent = '{"external":"untouched"}' + Set-Content -LiteralPath $externalSnapshotFile -Value $externalSnapshotContent -Encoding UTF8 + + New-Item -ItemType SymbolicLink -Path (Join-Path $baseFolder ".github") -Target $externalGithubFolder -Force | Out-Null + + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + + Get-ContentLF -Path $externalSnapshotFile | Should -Be $externalSnapshotContent + } + + It 'Throws and leaves the external target untouched when the snapshot file itself is a symlink redirecting elsewhere' -Skip:(-not $script:hasSymlinkCapability) { + $templateFolder = Join-Path $TestDrive "templateForSnapshotFileLinkEscape" + $baseFolder = Join-Path $TestDrive "baseWithSnapshotFileLinkEscape" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + Set-Content -LiteralPath $templateSettingsFile -Value '{"customALGoFiles":{"filesToInclude":[{"filter":"current.txt"}]}}' -Encoding UTF8 + + $externalTargetFile = Join-Path $TestDrive "externalSnapshotTargetFile.json" + $externalTargetContent = '{"external":"untouched"}' + Set-Content -LiteralPath $externalTargetFile -Value $externalTargetContent -Encoding UTF8 + + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + New-Item -ItemType SymbolicLink -Path $snapshotFile -Target $externalTargetFile -Force | Out-Null + + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + + Get-ContentLF -Path $externalTargetFile | Should -Be $externalTargetContent + } + + It 'Throws and leaves the external target untouched when the template folder ".github" is a symlink redirecting elsewhere' -Skip:(-not $script:hasSymlinkCapability) { + $templateFolder = Join-Path $TestDrive "templateWithGithubLinkEscape" + $baseFolder = Join-Path $TestDrive "baseForTemplateGithubLinkEscape" + $externalGithubFolder = Join-Path $TestDrive "externalGithubForTemplateLinkEscape" + New-Item -ItemType Directory -Path $templateFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path $externalGithubFolder -Force | Out-Null + + $externalSettingsFile = Join-Path $externalGithubFolder $RepoSettingsFileName + $externalSettingsContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"external.txt"}]}}' + Set-Content -LiteralPath $externalSettingsFile -Value $externalSettingsContent -Encoding UTF8 + + New-Item -ItemType SymbolicLink -Path (Join-Path $templateFolder ".github") -Target $externalGithubFolder -Force | Out-Null + + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + + Get-ContentLF -Path $externalSettingsFile | Should -Be $externalSettingsContent + } + + It 'Throws and leaves the external target and the existing snapshot untouched when the template settings file itself is a symlink redirecting elsewhere' -Skip:(-not $script:hasSymlinkCapability) { + $templateFolder = Join-Path $TestDrive "templateWithSettingsFileLinkEscape" + $baseFolder = Join-Path $TestDrive "baseForTemplateSettingsFileLinkEscape" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + + $externalTargetFile = Join-Path $TestDrive "externalTemplateSettingsTargetFile.json" + $externalTargetContent = '{"external":"untouched"}' + Set-Content -LiteralPath $externalTargetFile -Value $externalTargetContent -Encoding UTF8 + + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + New-Item -ItemType SymbolicLink -Path $templateSettingsFile -Target $externalTargetFile -Force | Out-Null + + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $snapshotContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"stale.txt"}]}}' + Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 + + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + + Get-ContentLF -Path $snapshotFile | Should -Be $snapshotContent + Get-ContentLF -Path $externalTargetFile | Should -Be $externalTargetContent + } + + It 'Throws and leaves the external target untouched when the base folder ".github" is a junction redirecting elsewhere' -Skip:(-not $script:isWindowsPlatform) { + $templateFolder = Join-Path $TestDrive "templateForBaseGithubJunctionEscape" + $baseFolder = Join-Path $TestDrive "baseWithGithubJunctionEscape" + $externalGithubFolder = Join-Path $TestDrive "externalGithubForBaseJunctionEscape" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path $baseFolder -Force | Out-Null + New-Item -ItemType Directory -Path $externalGithubFolder -Force | Out-Null + + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + Set-Content -LiteralPath $templateSettingsFile -Value '{"customALGoFiles":{"filesToInclude":[{"filter":"current.txt"}]}}' -Encoding UTF8 + + $externalSnapshotFile = Join-Path $externalGithubFolder $CustomTemplateRepoSettingsFileName + $externalSnapshotContent = '{"external":"untouched"}' + Set-Content -LiteralPath $externalSnapshotFile -Value $externalSnapshotContent -Encoding UTF8 + + New-Item -ItemType Junction -Path (Join-Path $baseFolder ".github") -Target $externalGithubFolder -Force | Out-Null + + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + + Get-ContentLF -Path $externalSnapshotFile | Should -Be $externalSnapshotContent + } + + It 'Throws and leaves the external target untouched when the template folder ".github" is a junction redirecting elsewhere' -Skip:(-not $script:isWindowsPlatform) { + $templateFolder = Join-Path $TestDrive "templateWithGithubJunctionEscape" + $baseFolder = Join-Path $TestDrive "baseForTemplateGithubJunctionEscape" + $externalGithubFolder = Join-Path $TestDrive "externalGithubForTemplateJunctionEscape" + New-Item -ItemType Directory -Path $templateFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path $externalGithubFolder -Force | Out-Null + + $externalSettingsFile = Join-Path $externalGithubFolder $RepoSettingsFileName + $externalSettingsContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"external.txt"}]}}' + Set-Content -LiteralPath $externalSettingsFile -Value $externalSettingsContent -Encoding UTF8 + + New-Item -ItemType Junction -Path (Join-Path $templateFolder ".github") -Target $externalGithubFolder -Force | Out-Null + + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + + Get-ContentLF -Path $externalSettingsFile | Should -Be $externalSettingsContent + } } Describe "GetFilesToUpdate (real template)" { From d8e4b547bc57f43236f51ef5d825a22c89b20eb0 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Fri, 25 Sep 2026 12:35:41 +0200 Subject: [PATCH 28/34] read settings without context, handle path issues, cleanup --- .../CheckForUpdates.HelperFunctions.ps1 | 33 ++++++---- Actions/CheckForUpdates/CheckForUpdates.ps1 | 3 +- Scenarios/settings.md | 2 + Tests/CheckForUpdates.Action.Test.ps1 | 66 ++++++++++++++++++- 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 9f6578f112..476e449420 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -862,7 +862,7 @@ function Test-PathLexicallyContained { .SYNOPSIS Resolves a path to its final physical location, following any symbolic links/junctions along the way. .DESCRIPTION -Walks $Path segment by segment starting from the most specific verified folder that lexically contains it, +Walks $Path segment by segment starting from the most specific trusted anchor that lexically contains it, resolving any symbolic links or junctions encountered along the way. .PARAMETER Path The literal path to resolve. @@ -872,8 +872,8 @@ reparse points within these paths are ignored (or they are otherwise known to be The function will start resolution from the most specific entry in this list that lexically contains $Path (or that equals $Path exactly). .OUTPUTS -The fully resolved physical path, or $null if resolution failed -(path not lexically contained within any of the anchor paths, or a reparse point chain exceeded the hop limit). +The resolved path (including any nonexistent trailing segments), or $null if a path segment could not be +inspected or the reparse point chain exceeded the hop limit. The filesystem root is a fallback anchor. #> function Resolve-PathPhysically { Param( @@ -889,11 +889,10 @@ function Resolve-PathPhysically { $hopLimit = 40 # matches the classic OS/.NET max-followed-symlinks limit (guards against cyclic chains) $hopCount = 0 - # Determine the most specific anchor folder that lexically contains the target path. This helps to minimize redundant checks for already verified path segments. - # Uses $realPath (already canonicalized/absolute), not the raw $Path, since GetPathRoot/Resolve-PathLexically require a rooted or resolvable path + # Start from the deepest trusted anchor that contains the target path. $anchorPath = @($AnchorPaths) + @([System.IO.Path]::GetPathRoot($realPath)) | # combine user-provided anchor folders with the root of the target path ForEach-Object { Resolve-PathLexically -Path $_ -AsDirectory } | # canonicalize each anchor folder to an absolute path as a directory - Sort-Object -Descending | # sort anchor folders by descending length to prioritize the most specific one (longest path first) + Sort-Object -Descending | # nested paths sort before their containing prefixes Where-Object { Test-PathLexicallyContained -Path $realPath -RootFolder $_ } | # filter only those anchor folders that lexically contain the target path Select-Object -First 1 @@ -910,11 +909,17 @@ function Resolve-PathPhysically { continue } - $item = Get-Item -LiteralPath $realPath -Force -ErrorAction SilentlyContinue - if (-not $item) { + try { + $item = Get-Item -LiteralPath $realPath -Force -ErrorAction Stop + } + catch [System.Management.Automation.ItemNotFoundException] { $resolveReparsePoints = $false continue } + catch { + OutputWarning "Path '$Path' could not be resolved: unable to inspect '$realPath'." + return $null + } if ($item.LinkType -notin @('SymbolicLink', 'Junction') -or -not $item.Target) { if ($item -is [System.IO.DirectoryInfo]) { @@ -923,9 +928,9 @@ function Resolve-PathPhysically { continue } - if ($hopCount++ -gt $hopLimit) { + if ($hopCount++ -ge $hopLimit) { # Cyclic or pathologically deep chain - fail closed, like the OS would refuse to resolve it too - OutputWarning "Path '$Path' could not be resolved: reparse point chain exceeded $($hopLimit) hops (cyclic or too deep) at '$realPath'. Treating as not contained." + OutputWarning "Path '$Path' could not be resolved: reparse point chain exceeded $($hopLimit) hops (cyclic or too deep) at '$realPath'." return $null } @@ -935,10 +940,10 @@ function Resolve-PathPhysically { } $realPath = Resolve-PathLexically -Path $realPath - # Determine the most specific anchor folder that lexically contains the target path. This helps to minimize redundant checks for already verified path segments. + # Recheck from the deepest trusted anchor after following the link target. $anchorPath = @($AnchorPaths) + @([System.IO.Path]::GetPathRoot($realPath)) | # combine user-provided anchor folders with the root of the target path ForEach-Object { Resolve-PathLexically -Path $_ -AsDirectory } | # canonicalize each anchor folder to an absolute path as a directory - Sort-Object -Descending | # sort anchor folders by descending length to prioritize the most specific one (longest path first) + Sort-Object -Descending | # nested paths sort before their containing prefixes Where-Object { Test-PathLexicallyContained -Path $realPath -RootFolder $_ } | # filter only those anchor folders that lexically contain the target path Select-Object -First 1 @@ -964,7 +969,8 @@ The literal path expected to be its own final physical location. An array of trusted anchor paths (folders, or specific files/reparse points) to start resolution from; reparse points within these paths are ignored (or they are otherwise known to be free of reparse points). .OUTPUTS -$true if $Path resolves to itself, otherwise $false. +$true if $Path resolves to itself (including a nonexistent trailing path), otherwise $false. +$false also means physical resolution failed. #> function Test-PathPhysicallyEqual { Param( @@ -1310,6 +1316,7 @@ function ReadSettingsWithCurrentCustomTemplateRepoSettings { if (Test-Path -LiteralPath $templateFolderRepoSettingsPath -PathType Leaf) { Copy-Item -LiteralPath $templateFolderRepoSettingsPath -Destination $baseFolderTemplateSettingsPath -Force } + # Match the initial read: system-file selection must not depend on the current execution context. return ReadSettings -baseFolder $baseFolder -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse } finally { diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index 86b148a082..5da865caec 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -51,7 +51,8 @@ if ($token) { # if $update is set to N, CheckForUpdates will only check for updates and output a warning if there are updates available # if $downloadLatest is set to true, CheckForUpdates will download the latest version of the template repository, else it will use the templateSha setting in the .github/AL-Go-Settings file -# Get Repo settings as a hashtable (do NOT read any specific project settings, nor any specific workflow, user or branch settings) +# Get repo settings independent of the build, project, workflow, user, branch, and trigger running this update. +# Repository-scoped and unconditional settings still apply. $repoSettings = ReadSettings -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse $templateSha = $repoSettings.templateSha diff --git a/Scenarios/settings.md b/Scenarios/settings.md index afe454fd1d..537a982863 100644 --- a/Scenarios/settings.md +++ b/Scenarios/settings.md @@ -189,6 +189,8 @@ to your [project settings file](#where-are-the-settings-located) will ensure tha - **users** settings will be applied for users matching the patterns - **triggers** settings will be applied when `GITHUB_EVENT_NAME` matches values (for example `push`, `pull_request`, `schedule`, `workflow_dispatch`) +When updating AL-Go System Files, settings are read without a build mode, project, workflow, user, branch, or trigger context. Conditions on any of those six contexts do not apply to the update, including conditions combined with a repository match. Unconditional settings and conditions matching the repository still apply. + **Note:** You can use `workflowDefaultInputs` within conditional settings to apply workflow input defaults only when certain conditions are met. For example, you could set different default values for specific workflows or branches. You could imagine that you could have an organizational settings variable containing: diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 6fe17cef89..a42d1f45aa 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -71,7 +71,7 @@ Describe "CheckForUpdates Action Tests" { } } -Describe "CheckForUpdates Action: physical path guards" { +Describe "CheckForUpdates Action: runtime behavior" { BeforeAll { $actionName = "CheckForUpdates" $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve @@ -88,6 +88,36 @@ Describe "CheckForUpdates Action: physical path guards" { Remove-Item -Path $rootFolder -Recurse -Force -ErrorAction SilentlyContinue } + It 'Reads initial settings without execution-specific contexts' { + $originalGitHubWorkspace = $env:GITHUB_WORKSPACE + $originalGitHubRepository = $env:GITHUB_REPOSITORY + try { + $env:GITHUB_WORKSPACE = $TestDrive + $env:GITHUB_REPOSITORY = 'contoso/context-policy-test' + + Mock DownloadAndImportBcContainerHelper {} + Mock ReadSettings { [PSCustomObject]@{ templateSha = 'aaaaaaa'; templateUrl = 'https://github.com/contoso/template@main'; type = 'PTE'; projects = @() } } + Mock DownloadTemplateRepository { return $TestDrive } + Mock GetSrcFolder { return $TestDrive } + Mock IsDirectALGo { return $true } + Mock GetProjectsFromRepository { return @('.') } + Mock GetFilesToUpdate { Write-Output -NoEnumerate @(); Write-Output -NoEnumerate @() } + Mock OutputNotice {} + + . $scriptPath -templateUrl 'https://github.com/contoso/template@main' -downloadLatest $true -update N + + Should -Invoke ReadSettings -Exactly 1 -ParameterFilter { + $buildMode -ceq '' -and $project -ceq '' -and $workflowName -ceq '' -and + $userName -ceq '' -and $branchName -ceq '' -and $trigger -ceq '' -and + ($null -eq $repoName -or $repoName -ceq $env:GITHUB_REPOSITORY) + } + } + finally { + $env:GITHUB_WORKSPACE = $originalGitHubWorkspace + $env:GITHUB_REPOSITORY = $originalGitHubRepository + } + } + It 'CheckForUpdates skips source files that do not physically resolve to themselves within the template folder(s)' -Skip:(-not $script:isWindowsPlatform) { $testTemplateFolder = Join-Path $rootFolder "srcGuardTemplate" $testExternalFolder = Join-Path $rootFolder "srcGuardExternal" @@ -2423,6 +2453,17 @@ Describe "Resolve-PathPhysically" { Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be ([System.IO.Path]::GetFullPath($path)) } + It 'Resolve-PathPhysically fails closed when a path segment cannot be inspected' { + $blockedPath = Join-Path $rootFolder 'blocked' + $path = Join-Path $blockedPath 'file.txt' + Mock Get-Item { throw [System.UnauthorizedAccessException]::new('Access denied') } -ParameterFilter { $LiteralPath -eq $blockedPath } + Mock OutputWarning {} + + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be $null + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $false + Should -Invoke OutputWarning -Times 2 -ParameterFilter { $message -like '*unable to inspect*' } + } + It 'Resolve-PathPhysically returns a folder anchor unchanged when Path equals the anchor exactly' { $folder = Join-Path $rootFolder "exactFolderAnchor" New-Item -Path $folder -ItemType Directory -Force | Out-Null @@ -3733,6 +3774,29 @@ Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { Get-ContentLF -Path $snapshotFile | Should -Be $snapshotContent } + It 'Reads refreshed settings without execution-specific contexts' { + $templateFolder = Join-Path $TestDrive 'templateWithContexts' + $baseFolder = Join-Path $TestDrive 'baseWithContexts' + New-Item -ItemType Directory -Path (Join-Path $templateFolder '.github') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder '.github') -Force | Out-Null + Mock ReadSettings { [pscustomobject]@{ templateSha = 'test' } } + + $originalGitHubRepository = $env:GITHUB_REPOSITORY + try { + $env:GITHUB_REPOSITORY = 'contoso/context-policy-test' + ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder | Out-Null + + Should -Invoke ReadSettings -Exactly 1 -ParameterFilter { + $buildMode -ceq '' -and $project -ceq '' -and $workflowName -ceq '' -and + $userName -ceq '' -and $branchName -ceq '' -and $trigger -ceq '' -and + ($null -eq $repoName -or $repoName -ceq $env:GITHUB_REPOSITORY) + } + } + finally { + $env:GITHUB_REPOSITORY = $originalGitHubRepository + } + } + It 'Removes a temporary snapshot when none existed before reading settings' { $templateFolder = Join-Path $TestDrive "templateWithoutSnapshot" $baseFolder = Join-Path $TestDrive "baseWithoutSnapshot" From ff032bd69839b4f670837e13bc3a455a67592fa4 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Sat, 26 Sep 2026 00:01:33 +0200 Subject: [PATCH 29/34] fixed issues with literal paths, symlinks/junctions and case-sensitivity --- .../CheckForUpdates.HelperFunctions.ps1 | 7 +- Actions/CheckForUpdates/CheckForUpdates.ps1 | 25 +- Actions/CheckForUpdates/yamlclass.ps1 | 2 +- Actions/Github-Helper.psm1 | 2 +- Tests/CheckForUpdates.Action.Test.ps1 | 578 ++++++++++++++---- Tests/GitHub-Helper.Test.ps1 | 21 + 6 files changed, 492 insertions(+), 143 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 476e449420..82ab8f8a68 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -710,7 +710,7 @@ function GetModifiedSettingsContent { $srcSettings = Get-ContentLF -Path $srcSettingsFile | ConvertFrom-Json $dstSettings = $null - if(Test-Path -Path $dstSettingsFile -PathType Leaf) { + if(Test-Path -LiteralPath $dstSettingsFile -PathType Leaf) { $dstSettings = Get-ContentLF -Path $dstSettingsFile | ConvertFrom-Json } @@ -921,6 +921,11 @@ function Resolve-PathPhysically { return $null } + if ($null -eq $item) { + # Required for PS5.1 as Get-Item may return $null instead of throwing when the item does not exist + $resolveReparsePoints = $false + continue + } if ($item.LinkType -notin @('SymbolicLink', 'Junction') -or -not $item.Target) { if ($item -is [System.IO.DirectoryInfo]) { $AnchorPaths += $item.FullName # this segment itself is confirmed not a reparse point diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index 5da865caec..6bc08b57c8 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -154,23 +154,30 @@ foreach($fileToInclude in $filesToInclude) { if(-not $originalSrcPath) { $originalSrcPath = $srcPath } + $hasDistinctOriginalSource = -not (GetPathStringComparer).Equals($originalSrcPath, $srcPath) # Skip files that do not physically resolve to themselves within the template folders if (-not (Test-PathPhysicallyEqual -Path $srcPath -AnchorPaths $templateFolders)) { - OutputWarning "Skipping file '$srcPath': source does not physically resolve to itself within the template folder(s). This may indicate a symlink/junction redirect." + OutputWarning "Skipping update for source file '$srcPath': source does not physically resolve to itself within the template folder(s). This may indicate a symlink/junction redirect." continue } - if ($originalSrcPath -ne $srcPath) { + if ($hasDistinctOriginalSource) { # Skip files with original files that do not physically resolve to themselves within the template folders if (-not (Test-PathPhysicallyEqual -Path $originalSrcPath -AnchorPaths $templateFolders)) { - OutputWarning "Skipping file '$srcPath': original source '$originalSrcPath' does not physically resolve to itself within the template folder(s). This may indicate a symlink/junction redirect." + OutputWarning "Skipping update for source file '$srcPath': original source '$originalSrcPath' does not physically resolve to itself within the template folder(s). This may indicate a symlink/junction redirect." continue } } $dstPath = $fileToInclude.destinationFullPath - $dstFileExists = Test-Path -Path $dstPath -PathType Leaf + # Skip files with destinations that do not physically resolve to themselves within the base folder + if (-not (Test-PathPhysicallyEqual -Path $dstPath -AnchorPaths @($baseFolder))) { + OutputWarning "Skipping update for source file '$srcPath': destination '$dstPath' does not physically resolve to itself. This may indicate a symlink/junction redirect." + continue + } + + $dstFileExists = Test-Path -LiteralPath $dstPath -PathType Leaf Write-Host "Processing file: $srcPath -> $dstPath (type: $type)" @@ -196,7 +203,7 @@ foreach($fileToInclude in $filesToInclude) { ReplaceOwnerRepoAndBranch -srcContent ([ref]$srcContent) -templateOwner $templateOwner -templateBranch $templateBranch } - if ($type -eq 'workflow' -and $originalSrcPath -ne $srcPath) { + if ($type -eq 'workflow' -and $hasDistinctOriginalSource) { # Apply customizations from custom template repository Write-Host "Apply customizations from custom template repository, file: $srcPath" [Yaml]::ApplyTemplateCustomizations([ref] $srcContent, $srcPath) @@ -230,8 +237,8 @@ foreach($fileToInclude in $filesToInclude) { Push-Location -Path $baseFolder # Remove files that are in $filesToExclude and exist in the repository -$removeFiles = $filesToExclude | Where-Object { $_ -and (Test-Path -Path $_.destinationFullPath -PathType Leaf) } | ForEach-Object { - $relativePath = Resolve-Path -Path $_.destinationFullPath -Relative +$removeFiles = $filesToExclude | Where-Object { $_ -and (Test-Path -LiteralPath $_.destinationFullPath -PathType Leaf) } | ForEach-Object { + $relativePath = Resolve-Path -LiteralPath $_.destinationFullPath -Relative Write-Host "File marked for removal: $relativePath" $relativePath } @@ -291,10 +298,10 @@ else { # Create the destination folder if it doesn't exist $path = [System.IO.Path]::GetDirectoryName($_.DstFile) - if ($path -and -not (Test-Path -path $path -PathType Container)) { + if ($path -and -not (Test-Path -LiteralPath $path -PathType Container)) { New-Item -Path $path -ItemType Directory | Out-Null } - if (([System.IO.Path]::GetFileName($_.DstFile) -eq "RELEASENOTES.copy.md") -and (Test-Path $_.DstFile)) { + if (([System.IO.Path]::GetFileName($_.DstFile) -eq "RELEASENOTES.copy.md") -and (Test-Path -LiteralPath $_.DstFile)) { # Read the release notes of the version currently installed $oldReleaseNotes = Get-ContentLF -Path $_.DstFile # Get the release notes of the new version (for the PR body) diff --git a/Actions/CheckForUpdates/yamlclass.ps1 b/Actions/CheckForUpdates/yamlclass.ps1 index 68ee7683f3..1a03a3464a 100644 --- a/Actions/CheckForUpdates/yamlclass.ps1 +++ b/Actions/CheckForUpdates/yamlclass.ps1 @@ -19,7 +19,7 @@ class Yaml { # Static load function to load a Yaml file into a Yaml class static [Yaml] Load([string] $filename) { - $fileContent = Get-Content -Path $filename -Encoding UTF8 + $fileContent = Get-Content -LiteralPath $filename -Encoding UTF8 return [Yaml]::new($fileContent) } diff --git a/Actions/Github-Helper.psm1 b/Actions/Github-Helper.psm1 index 3c93fed5cc..4eb2d425d6 100644 --- a/Actions/Github-Helper.psm1 +++ b/Actions/Github-Helper.psm1 @@ -859,7 +859,7 @@ function Get-ContentLF { ) Process { - (Get-Content -Path $path -Encoding UTF8 -Raw).Replace("`r", "").TrimEnd("`n") + (Get-Content -LiteralPath $path -Encoding UTF8 -Raw).Replace("`r", "").TrimEnd("`n") } } diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index a42d1f45aa..370e8293e3 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -80,7 +80,7 @@ Describe "CheckForUpdates Action: runtime behavior" { . (Join-Path -Path $scriptRoot -ChildPath "..\AL-Go-Helper.ps1" -Resolve) . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") - $rootFolder = Join-Path $PSScriptRoot "checkForUpdatesGuardTests" + $rootFolder = Join-Path $PSScriptRoot "checkForUpdatesRuntimeTests" New-Item -Path $rootFolder -ItemType Directory -Force | Out-Null } @@ -88,156 +88,451 @@ Describe "CheckForUpdates Action: runtime behavior" { Remove-Item -Path $rootFolder -Recurse -Force -ErrorAction SilentlyContinue } - It 'Reads initial settings without execution-specific contexts' { + BeforeEach { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalGitHubWorkspace', Justification = 'False positive.')] $originalGitHubWorkspace = $env:GITHUB_WORKSPACE + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalGitHubRepository', Justification = 'False positive.')] $originalGitHubRepository = $env:GITHUB_REPOSITORY - try { - $env:GITHUB_WORKSPACE = $TestDrive - $env:GITHUB_REPOSITORY = 'contoso/context-policy-test' + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalLocation', Justification = 'False positive.')] + $originalLocation = Get-Location - Mock DownloadAndImportBcContainerHelper {} - Mock ReadSettings { [PSCustomObject]@{ templateSha = 'aaaaaaa'; templateUrl = 'https://github.com/contoso/template@main'; type = 'PTE'; projects = @() } } - Mock DownloadTemplateRepository { return $TestDrive } - Mock GetSrcFolder { return $TestDrive } - Mock IsDirectALGo { return $true } - Mock GetProjectsFromRepository { return @('.') } - Mock GetFilesToUpdate { Write-Output -NoEnumerate @(); Write-Output -NoEnumerate @() } - Mock OutputNotice {} + $testRepoName = 'contoso/check-for-updates-runtime-tests' - . $scriptPath -templateUrl 'https://github.com/contoso/template@main' -downloadLatest $true -update N + $testTemplateFolder = Join-Path $rootFolder 'template' + $testWorkspaceFolder = Join-Path $rootFolder 'workspace' + $testCloneRoot = Join-Path $rootFolder 'clone' - Should -Invoke ReadSettings -Exactly 1 -ParameterFilter { - $buildMode -ceq '' -and $project -ceq '' -and $workflowName -ceq '' -and - $userName -ceq '' -and $branchName -ceq '' -and $trigger -ceq '' -and - ($null -eq $repoName -or $repoName -ceq $env:GITHUB_REPOSITORY) - } + $testSettings = @{ + templateSha = 'sha123456789' + templateUrl = 'https://github.com/microsoft/AL-Go-PTE@main' + type = 'PTE' + projects = @() + 'runs-on' = 'windows-latest' + shell = 'powershell' } - finally { - $env:GITHUB_WORKSPACE = $originalGitHubWorkspace - $env:GITHUB_REPOSITORY = $originalGitHubRepository - } - } - It 'CheckForUpdates skips source files that do not physically resolve to themselves within the template folder(s)' -Skip:(-not $script:isWindowsPlatform) { - $testTemplateFolder = Join-Path $rootFolder "srcGuardTemplate" - $testExternalFolder = Join-Path $rootFolder "srcGuardExternal" - $testWorkspaceFolder = Join-Path $rootFolder "srcGuardWorkspace" - $originalGitHubWorkspace = $env:GITHUB_WORKSPACE - try { - New-Item -Path $testTemplateFolder -ItemType Directory -Force | Out-Null - New-Item -Path $testExternalFolder -ItemType Directory -Force | Out-Null - New-Item -Path $testWorkspaceFolder -ItemType Directory -Force | Out-Null - - # A legitimate file directly in the template folder - $legitFile = Join-Path $testTemplateFolder "legit.txt" - Set-Content -LiteralPath $legitFile -Value "legit content" - - # A file reachable only through a junction subfolder that redirects outside the template folder - - # the target file doesn't need to exist for the guard to trip, the folder-level redirect is enough - New-Item -ItemType Junction -Path (Join-Path $testTemplateFolder "redirectSub") -Target $testExternalFolder -Force | Out-Null - $redirectedFile = Join-Path $testTemplateFolder "redirectSub/redirected.txt" - Set-Content -LiteralPath $redirectedFile -Value "redirected content" - - $testFilesToInclude = @( - @{ sourceFullPath = $legitFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "legit.txt") } - @{ sourceFullPath = $redirectedFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirected.txt") } - @{ sourceFullPath = $legitFile; originalSourceFullPath = $redirectedFile; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirected.txt") } - ) + $testFilesToInclude = @() + $testFilesToExclude = @() - Mock DownloadAndImportBcContainerHelper {} - Mock ReadSettings { [PSCustomObject]@{ templateSha = 'aaaaaaa'; templateUrl = 'https://github.com/contoso/template@main'; type = 'PTE'; projects = @() } } - Mock DownloadTemplateRepository { return $testTemplateFolder } - Mock GetSrcFolder { return $testTemplateFolder } - Mock IsDirectALGo { return $true } - Mock GetProjectsFromRepository { return @('.') } - Mock GetFilesToUpdate { Write-Output -NoEnumerate $testFilesToInclude; Write-Output -NoEnumerate @() } - Mock OutputWarning {} - Mock OutputNotice {} + $fakeToken = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('fake-pat')) - $env:GITHUB_WORKSPACE = $testWorkspaceFolder + Mock DownloadAndImportBcContainerHelper {} + Mock ReadSettings { [PSCustomObject]$testSettings } + Mock DownloadTemplateRepository { return $testTemplateFolder } + Mock GetSrcFolder { return $testTemplateFolder } + Mock IsDirectALGo { return $true } + Mock GetProjectsFromRepository { return @('.') } + Mock GetFilesToUpdate { Write-Output $testFilesToInclude -NoEnumerate; Write-Output $testFilesToExclude -NoEnumerate } + Mock OutputWarning {} + Mock OutputNotice {} + Mock RunAndCheck { return 'sha123456789' } + Mock invoke-git {} + Mock GetAccessToken { return $fakeToken } + Mock gh { '[]' } + Mock CloneIntoNewFolder { Set-Location -Path $testCloneRoot; "https://fake.example.com/$testRepoName.git"; 'update-al-go-system-files/branch' } + Mock CommitFromNewFolder { return $true } + Mock UpdateSettingsFile {} - . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update N + New-Item -Path $testTemplateFolder -ItemType Directory -Force | Out-Null + New-Item -Path $testWorkspaceFolder -ItemType Directory -Force | Out-Null + New-Item -Path $testCloneRoot -ItemType Directory -Force | Out-Null - Should -Invoke OutputWarning -Exactly 2 -ParameterFilter { $message -like "Skipping file*redirected.txt*does not physically resolve*" } - $updateFiles.Count | Should -Be 1 - $updateFiles[0].DstFile | Should -Be "legit.txt" + Set-Location $testWorkspaceFolder + $env:GITHUB_WORKSPACE = $testWorkspaceFolder + $env:GITHUB_REPOSITORY = $testRepoName + } + + AfterEach { + Set-Location $originalLocation + $env:GITHUB_WORKSPACE = $originalGitHubWorkspace + $env:GITHUB_REPOSITORY = $originalGitHubRepository + + if (Test-Path -LiteralPath $testTemplateFolder) { + Remove-Item -Path $testTemplateFolder -Recurse -Force } - finally { - $env:GITHUB_WORKSPACE = $originalGitHubWorkspace - Remove-Item -Path $testTemplateFolder, $testExternalFolder, $testWorkspaceFolder -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $testWorkspaceFolder) { + Remove-Item -Path $testWorkspaceFolder -Recurse -Force + } + if (Test-Path -LiteralPath $testCloneRoot) { + Remove-Item -Path $testCloneRoot -Recurse -Force } } - It 'CheckForUpdates skips destination files that do not physically resolve to themselves when updating or removing files' -Skip:(-not $script:isWindowsPlatform) { - $testTemplateFolder = Join-Path $rootFolder "dstGuardTemplate" - $testExternalFolder = Join-Path $rootFolder "dstGuardExternal" - $testBaseFolder = Join-Path $rootFolder "dstGuardBase" - $testCloneRoot = Join-Path $rootFolder "dstGuardClone" - $originalGitHubWorkspace = $env:GITHUB_WORKSPACE - $originalLocation = Get-Location - try { - New-Item -Path $testTemplateFolder -ItemType Directory -Force | Out-Null - New-Item -Path $testExternalFolder -ItemType Directory -Force | Out-Null - New-Item -Path $testBaseFolder -ItemType Directory -Force | Out-Null - New-Item -Path $testCloneRoot -ItemType Directory -Force | Out-Null - - # Source files in the "template" - $legitSrcFile = Join-Path $testTemplateFolder "legit.txt" - Set-Content -LiteralPath $legitSrcFile -Value "legit content" - $trapSrcFile = Join-Path $testTemplateFolder "trap.txt" - Set-Content -LiteralPath $trapSrcFile -Value "trap content" - - # A file already present in the original checkout, slated for removal - New-Item -Path (Join-Path $testBaseFolder "redirectSub") -ItemType Directory -Force | Out-Null - Set-Content -LiteralPath (Join-Path $testBaseFolder "redirectSub/oldfile.txt") -Value "old content" - - # In the FRESH clone only, "redirectSub" is a junction that redirects outside the clone root - - # simulates a reparse point introduced by a newer commit that wasn't present in the original checkout - New-Item -ItemType Junction -Path (Join-Path $testCloneRoot "redirectSub") -Target $testExternalFolder -Force | Out-Null - Set-Content -LiteralPath (Join-Path $testExternalFolder "oldfile.txt") -Value "cloned old content" - - $testFilesToInclude = @( - @{ sourceFullPath = $legitSrcFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testBaseFolder "legit.txt") } - @{ sourceFullPath = $trapSrcFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testBaseFolder "redirectSub/trap.txt") } - ) - $testFilesToExclude = @( - @{ destinationFullPath = (Join-Path $testBaseFolder "redirectSub/oldfile.txt") } - ) + It 'Reads initial settings without execution-specific contexts' { + . $scriptPath -templateUrl $testSettings.templateUrl -downloadLatest $true -update N + + Should -Invoke ReadSettings -Exactly 1 -ParameterFilter { + $buildMode -ceq '' -and $project -ceq '' -and $workflowName -ceq '' -and + $userName -ceq '' -and $branchName -ceq '' -and $trigger -ceq '' -and + ($null -eq $repoName -or $repoName -ceq $env:GITHUB_REPOSITORY) + } + } + + It 'Reads, updates and removes with literal bracketed paths' { + $newSource = Join-Path $testTemplateFolder 'New[1].txt' + $newDestination = Join-Path $testWorkspaceFolder 'New[1].txt' + Set-Content -LiteralPath $newSource -Value 'new content' + Set-Content -LiteralPath (Join-Path $testTemplateFolder 'New1.txt') -Value 'wrong source' + Set-Content -LiteralPath (Join-Path $testWorkspaceFolder 'New1.txt') -Value 'wrong destination' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'New1.txt') -Value 'keep me' + + $changedSource = Join-Path $testTemplateFolder 'Changed[1].txt' + $changedDestination = Join-Path $testWorkspaceFolder 'Changed[1].txt' + Set-Content -LiteralPath $changedSource -Value 'new content' + Set-Content -LiteralPath $changedDestination -Value 'old content' + Set-Content -LiteralPath (Join-Path $testTemplateFolder 'Changed1.txt') -Value 'wrong source' + Set-Content -LiteralPath (Join-Path $testWorkspaceFolder 'Changed1.txt') -Value 'wrong destination' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Changed[1].txt') -Value 'old content' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Changed1.txt') -Value 'keep me' + + $unchangedSource = Join-Path $testTemplateFolder 'Same[1].txt' + $unchangedDestination = Join-Path $testWorkspaceFolder 'Same[1].txt' + Set-Content -LiteralPath $unchangedSource -Value 'same content' + Set-Content -LiteralPath $unchangedDestination -Value 'same content' + Set-Content -LiteralPath (Join-Path $testTemplateFolder 'Same1.txt') -Value 'wrong source' + Set-Content -LiteralPath (Join-Path $testWorkspaceFolder 'Same1.txt') -Value 'wrong destination' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Same[1].txt') -Value 'same content' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Same1.txt') -Value 'keep me' + + $workflowSource = Join-Path $testTemplateFolder 'Workflow[1].yaml' + $workflowOriginalSource = Join-Path $testTemplateFolder 'WorkflowOriginal[1].yaml' + $workflowDestination = Join-Path $testWorkspaceFolder 'Workflow[1].yaml' + $workflowLines = @('jobs:', ' Build:', ' runs-on: [ windows-latest ]') + $workflowTemplateCustomJobLines = @(' CustomJob-Template:', ' runs-on: [ windows-latest ]') + $workflowDestinationCustomJobLines = @(' CustomJob-Destination:', ' runs-on: [ windows-latest ]') + Set-Content -LiteralPath $workflowSource -Value (@('name: Custom') + $workflowLines + $workflowTemplateCustomJobLines) + Set-Content -LiteralPath $workflowOriginalSource -Value (@('name: Original') + $workflowLines) + Set-Content -LiteralPath $workflowDestination -Value (@('name: Destination') + $workflowLines + $workflowDestinationCustomJobLines) + Set-Content -LiteralPath (Join-Path $testTemplateFolder 'Workflow1.yaml') -Value (@('name: Wrong custom') + $workflowLines) + Set-Content -LiteralPath (Join-Path $testTemplateFolder 'WorkflowOriginal1.yaml') -Value (@('name: Wrong original') + $workflowLines) + Set-Content -LiteralPath (Join-Path $testWorkspaceFolder 'Workflow1.yaml') -Value (@('name: Wrong destination') + $workflowLines) + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Workflow[1].yaml') -Value (@('name: Old content') + $workflowLines) + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Workflow1.yaml') -Value (@('name: Keep me') + $workflowLines) + + $excludedDestination = Join-Path $testWorkspaceFolder 'Old[1].txt' + Set-Content -LiteralPath $excludedDestination -Value 'remove me' + Set-Content -LiteralPath (Join-Path $testWorkspaceFolder 'Old1.txt') -Value 'keep me' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Old[1].txt') -Value 'remove me' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Old1.txt') -Value 'keep me' + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $newSource; originalSourceFullPath = $null; type = ''; destinationFullPath = $newDestination } + @{ sourceFullPath = $changedSource; originalSourceFullPath = $null; type = ''; destinationFullPath = $changedDestination } + @{ sourceFullPath = $unchangedSource; originalSourceFullPath = $null; type = ''; destinationFullPath = $unchangedDestination } + @{ sourceFullPath = $workflowSource; originalSourceFullPath = $workflowOriginalSource; type = 'workflow'; destinationFullPath = (Join-Path $testWorkspaceFolder 'Workflow[1].yaml') } + ) + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToExclude', Justification = 'False positive.')] + $testFilesToExclude = @( + @{ destinationFullPath = $excludedDestination } + ) - Mock DownloadAndImportBcContainerHelper {} - Mock ReadSettings { [PSCustomObject]@{ templateSha = 'aaaaaaa'; templateUrl = 'https://github.com/contoso/template@main'; type = 'PTE'; projects = @() } } - Mock DownloadTemplateRepository { return $testTemplateFolder } - Mock GetSrcFolder { return $testTemplateFolder } - Mock IsDirectALGo { return $true } - Mock GetProjectsFromRepository { return @('.') } - Mock GetFilesToUpdate { Write-Output -NoEnumerate $testFilesToInclude; Write-Output -NoEnumerate $testFilesToExclude } - Mock OutputWarning {} - Mock OutputNotice {} - Mock RunAndCheck { return "deadbeef1234567" } - Mock invoke-git {} - Mock GetAccessToken { return "fake-token" } - Mock gh { '[]' } - Mock CloneIntoNewFolder { Set-Location -Path $testCloneRoot; 'https://fake.example.com/repo.git'; 'update-al-go-system-files/branch' } - Mock CommitFromNewFolder { return $true } - Mock UpdateSettingsFile {} + . $scriptPath -templateUrl 'https://github.com/contoso/template@main' -downloadLatest $true -update Y -updateBranch 'update-al-go-system-files' -token $fakeToken -directCommit $true -actor 'test-actor' - $env:GITHUB_WORKSPACE = $testBaseFolder - $fakeToken = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("fake-pat")) + $updateFiles.Count | Should -Be 3 + $updateFiles[0].DstFile | Should -Be 'New[1].txt' + $updateFiles[0].content | Should -Be 'new content' + $updateFiles[1].DstFile | Should -Be 'Changed[1].txt' + $updateFiles[1].content | Should -Be 'new content' + $updateFiles[2].DstFile | Should -Be 'Workflow[1].yaml' + $updateFiles[2].content | Should -Match '(?s)^name: Original.*CustomJob-Template:.*CustomJob-Destination:' + @($removeFiles).Count | Should -Be 1 + @($removeFiles)[0] | Should -Be (Join-Path '.' 'Old[1].txt') - . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update Y -updateBranch "update-al-go-system-files" -token $fakeToken -directCommit $true -actor "test-actor" + Get-Content -LiteralPath (Join-Path $testCloneRoot 'New[1].txt') | Should -Be 'new content' + Get-Content -LiteralPath (Join-Path $testCloneRoot 'New1.txt') | Should -Be 'keep me' - $updateFiles.Count | Should -Be 2 - Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping update*trap.txt*" } - Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping removal*oldfile.txt*" } + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Changed[1].txt') | Should -Be 'new content' + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Changed1.txt') | Should -Be 'keep me' - # The legitimate update was actually written to the (mocked) clone root; the trapped one was not - Test-Path -Path (Join-Path $testCloneRoot "legit.txt") -PathType Leaf | Should -Be $true - } - finally { - Set-Location -Path $originalLocation - $env:GITHUB_WORKSPACE = $originalGitHubWorkspace - Remove-Item -Path $testTemplateFolder, $testExternalFolder, $testBaseFolder, $testCloneRoot -Recurse -Force -ErrorAction SilentlyContinue - } + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Same[1].txt') | Should -Be 'same content' + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Same1.txt') | Should -Be 'keep me' + + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Workflow[1].yaml') -Raw | Should -Match '(?s)^name: Original.*CustomJob-Template:.*CustomJob-Destination:' + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Workflow1.yaml') -Raw | Should -Match '^name: Keep me' + + Test-Path -LiteralPath (Join-Path $testCloneRoot 'Old[1].txt') | Should -BeFalse + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Old1.txt') | Should -Be 'keep me' + } + + It 'Rejects an external original source on Linux' -Skip:(-not $script:isLinuxPlatform) { + $invalidSource = Join-Path $testTemplateFolder 'File.txt' + $invalidOriginalSource = Join-Path $testTemplateFolder 'file.txt' + Set-Content -LiteralPath $invalidSource -Value 'safe content' + $external = Join-Path $TestDrive 'file.txt' + Set-Content -LiteralPath $external -Value 'external content' + New-Item -ItemType SymbolicLink -Path $invalidOriginalSource -Target $external | Out-Null + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $invalidSource; originalSourceFullPath = $invalidOriginalSource; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder 'File.txt') } + ) + + . $scriptPath -templateUrl 'https://github.com/contoso/template@main' -downloadLatest $true -update N + + Should -Invoke OutputWarning -Exactly 1 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like '*original source*does not physically resolve*' } + $updateFiles.Count | Should -Be 0 + } + + It 'Applies workflow customizations from case-distinct original sources on Linux' -Skip:(-not $script:isLinuxPlatform) { + $workflowSource = Join-Path $testTemplateFolder 'Workflow.yaml' + $workflowOriginalSource = Join-Path $testTemplateFolder 'WORKFLOW.yaml' + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'CustomizedYamlSnippet-TemplateRepository.txt') -Destination $workflowSource + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'YamlSnippet.txt') -Destination $workflowOriginalSource + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $workflowSource; originalSourceFullPath = $workflowOriginalSource; type = 'workflow'; destinationFullPath = (Join-Path $testWorkspaceFolder 'Workflow.yaml') } + ) + + . $scriptPath -templateUrl 'https://github.com/contoso/template@main' -downloadLatest $true -update N + + Should -Invoke OutputWarning -Exactly 1 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like '*There are updates for your AL-Go system*' } + $updateFiles.Count | Should -Be 1 + $updateFiles[0].DstFile | Should -Be 'Workflow.yaml' + $updateFiles[0].content | Should -Match 'CustomJob-MyCustomTemplateJob:' + } + + It 'Applies no workflow customizations from case-distinct original sources on Windows' -Skip:(-not $script:isWindowsPlatform) { + $workflowSource = Join-Path $testTemplateFolder 'Workflow.yaml' + $workflowOriginalSource = Join-Path $testTemplateFolder 'WORKFLOW.yaml' + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'CustomizedYamlSnippet-TemplateRepository.txt') -Destination $workflowSource + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'YamlSnippet.txt') -Destination $workflowOriginalSource + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $workflowSource; originalSourceFullPath = $workflowOriginalSource; type = 'workflow'; destinationFullPath = (Join-Path $testWorkspaceFolder 'Workflow.yaml') } + ) + + . $scriptPath -templateUrl 'https://github.com/contoso/template@main' -downloadLatest $true -update N + + Should -Invoke OutputWarning -Exactly 1 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like '*There are updates for your AL-Go system*' } + $updateFiles.Count | Should -Be 1 + $updateFiles[0].DstFile | Should -Be 'Workflow.yaml' + $updateFiles[0].content | Should -Not -Match 'CustomJob-MyCustomTemplateJob:' + } + + It 'CheckForUpdates skips source files that do not physically resolve to themselves using junctions' -Skip:(-not $script:isWindowsPlatform) { + # A legitimate file directly in the template folder + $legitTemplateFile = Join-Path $testTemplateFolder "legit.txt" + Set-Content -LiteralPath $legitTemplateFile -Value "legit content" + + # Create a redirection folder and a junction pointing back to the template folder + $redirectedTemplateFolder = Join-Path $testTemplateFolder "redirected" + $redirectedTemplateFile = Join-Path $redirectedTemplateFolder "redirected.txt" + New-Item -ItemType Junction -Path $redirectedTemplateFolder -Target $testTemplateFolder -Force | Out-Null + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $legitTemplateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "legit.txt") } + @{ sourceFullPath = $redirectedTemplateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirected.txt") } + @{ sourceFullPath = $legitTemplateFile; originalSourceFullPath = $redirectedTemplateFile; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirected.txt") } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update N + + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "Skipping update for source file '*redirected.txt': source does not physically resolve*" } + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "Skipping update for source file '*': original source '*redirected.txt' does not physically resolve*" } + $updateFiles.Count | Should -Be 1 + $updateFiles[0].DstFile | Should -Be "legit.txt" + } + + It 'CheckForUpdates skips source files that do not physically resolve to themselves using symlinks' -Skip:(-not $script:hasSymlinkCapability) { + # A legitimate file directly in the template folder + $legitTemplateFile = Join-Path $testTemplateFolder "legit.txt" + Set-Content -LiteralPath $legitTemplateFile -Value "legit content" + + # Create a redirection folder and a symbolic link pointing back to the template folder + $redirectedTemplateFolder = Join-Path $testTemplateFolder "redirected" + $redirectedTemplateFile = Join-Path $redirectedTemplateFolder "redirected.txt" + New-Item -ItemType SymbolicLink -Path $redirectedTemplateFolder -Target $testTemplateFolder -Force | Out-Null + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $legitTemplateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "legit.txt") } + @{ sourceFullPath = $redirectedTemplateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirected.txt") } + @{ sourceFullPath = $legitTemplateFile; originalSourceFullPath = $redirectedTemplateFile; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirected.txt") } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update N + + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "Skipping update for source file '*redirected.txt': source does not physically resolve*" } + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "Skipping update for source file '*': original source '*redirected.txt' does not physically resolve*" } + $updateFiles.Count | Should -Be 1 + $updateFiles[0].DstFile | Should -Be "legit.txt" + } + + It 'CheckForUpdates skips source files with case-distinct original sources that do not physically resolve to themselves using symlinks on Linux' -Skip:(-not $script:isLinuxPlatform) { + $directTemplateFolder = Join-Path $testTemplateFolder "folder" + $directTemplateFile = Join-Path $directTemplateFolder "file.txt" + New-Item -ItemType Directory -Path $directTemplateFolder -Force | Out-Null + Set-Content -LiteralPath $directTemplateFile -Value "direct content" + + $redirectedTemplateFolder = Join-Path $testTemplateFolder "FOLDER" + $redirectedTemplateFile = Join-Path $redirectedTemplateFolder "file.txt" + New-Item -ItemType SymbolicLink -Path $redirectedTemplateFolder -Target $testTemplateFolder -Force | Out-Null + Set-Content -LiteralPath $redirectedTemplateFile -Value "redirected content" + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $directTemplateFile; originalSourceFullPath = $redirectedTemplateFile; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "file.txt") } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update N + + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -clike "Skipping update for source file '*folder*': original source '*FOLDER*' does not physically resolve*" } + $updateFiles.Count | Should -Be 0 + } + + It 'CheckForUpdates skips destination files that do not physically resolve to themselves when updating files using junctions' -Skip:(-not $script:isWindowsPlatform) { + # A file in the template folder + $templateFile = Join-Path $testTemplateFolder "template.txt" + Set-Content -LiteralPath $templateFile -Value "template content" + + # A legitimate file directly in the clone folder + $legitCloneFile = Join-Path $testCloneRoot "legit.txt" + + # Create a redirected folder and a symbolic link pointing back to the workspace folder + $redirectedWorkspaceFolder = Join-Path $testWorkspaceFolder "redirectedInWorkspace" + New-Item -ItemType Junction -Path $redirectedWorkspaceFolder -Target $testWorkspaceFolder -Force | Out-Null + + # Create a redirected folder and a symbolic link pointing back to the clone folder + $redirectedCloneFolder = Join-Path $testCloneRoot "redirectedInClone" + New-Item -ItemType Junction -Path $redirectedCloneFolder -Target $testCloneRoot -Force | Out-Null + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $templateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "legit.txt") } + @{ sourceFullPath = $templateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirectedInWorkspace/redirected.txt") } + @{ sourceFullPath = $templateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirectedInClone/redirected.txt") } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update Y -updateBranch "update-al-go-system-files" -token $fakeToken -directCommit $true -actor "test-actor" + + $updateFiles.Count | Should -Be 2 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping update for source file '*template.txt':*'*redirectedInWorkspace*redirected.txt'*" } + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping update of '*redirectedInClone*redirected.txt'*" } + + # The legitimate update was actually written to the (mocked) clone root; the redirected ones were not + Test-Path -Path $legitCloneFile -PathType Leaf | Should -Be $true + Test-Path -Path (Join-Path $testCloneRoot "redirectedInWorkspace/redirected.txt") -PathType Leaf | Should -Be $false + Test-Path -Path (Join-Path $testCloneRoot "redirectedInClone/redirected.txt") -PathType Leaf | Should -Be $false + } + + It 'CheckForUpdates skips destination files that do not physically resolve to themselves when updating files using symlinks' -Skip:(-not $script:hasSymlinkCapability) { + # A file in the template folder + $templateFile = Join-Path $testTemplateFolder "template.txt" + Set-Content -LiteralPath $templateFile -Value "template content" + + # A legitimate file directly in the clone folder + $legitCloneFile = Join-Path $testCloneRoot "legit.txt" + + # Create a redirected folder and a symbolic link pointing back to the workspace folder + $redirectedWorkspaceFolder = Join-Path $testWorkspaceFolder "redirectedInWorkspace" + New-Item -ItemType SymbolicLink -Path $redirectedWorkspaceFolder -Target $testWorkspaceFolder -Force | Out-Null + + # Create a redirected folder and a symbolic link pointing back to the clone folder + $redirectedCloneFolder = Join-Path $testCloneRoot "redirectedInClone" + New-Item -ItemType SymbolicLink -Path $redirectedCloneFolder -Target $testCloneRoot -Force | Out-Null + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $templateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "legit.txt") } + @{ sourceFullPath = $templateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirectedInWorkspace/redirected.txt") } + @{ sourceFullPath = $templateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirectedInClone/redirected.txt") } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update Y -updateBranch "update-al-go-system-files" -token $fakeToken -directCommit $true -actor "test-actor" + + $updateFiles.Count | Should -Be 2 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping update for source file '*template.txt':*'*redirectedInWorkspace*redirected.txt'*" } + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping update of '*redirectedInClone*redirected.txt'*" } + + # The legitimate update was actually written to the (mocked) clone root; the redirected ones were not + Test-Path -Path $legitCloneFile -PathType Leaf | Should -Be $true + Test-Path -Path (Join-Path $testCloneRoot "redirectedInWorkspace/redirected.txt") -PathType Leaf | Should -Be $false + Test-Path -Path (Join-Path $testCloneRoot "redirectedInClone/redirected.txt") -PathType Leaf | Should -Be $false + } + + It 'CheckForUpdates skips destination files that do not physically resolve to themselves when removing files using junctions' -Skip:(-not $script:isWindowsPlatform) { + $relativeLegitFile = "legit.txt" + $relativeRedirectedInWorkspaceFolder = "redirectedInWorkspace" + $relativeRedirectedInWorkspaceFile = Join-Path $relativeRedirectedInWorkspaceFolder "redirected.txt" + $relativeRedirectedInCloneFolder = "redirectedInClone" + $relativeRedirectedInCloneFile = Join-Path $relativeRedirectedInCloneFolder "redirected.txt" + + # Create folders, files and junctions in workspace folder + Set-Content -Path (Join-Path $testWorkspaceFolder $relativeLegitFile) -Value "legit content" -Force + New-Item -ItemType Junction -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInWorkspaceFolder) -Target $testWorkspaceFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInCloneFolder) -Force | Out-Null + Set-Content -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInWorkspaceFile) -Value "redirected in workspace content" -Force + Set-Content -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInCloneFile) -Value "redirected in clone content" -Force + + # Create folders, files and junctions in clone folder + Set-Content -Path (Join-Path $testCloneRoot $relativeLegitFile) -Value "legit content" -Force + New-Item -ItemType Directory -Path (Join-Path $testCloneRoot $relativeRedirectedInWorkspaceFolder) -Force | Out-Null + New-Item -ItemType Junction -Path (Join-Path $testCloneRoot $relativeRedirectedInCloneFolder) -Target $testCloneRoot -Force | Out-Null + Set-Content -Path (Join-Path $testCloneRoot $relativeRedirectedInWorkspaceFile) -Value "redirected in workspace content" -Force + Set-Content -Path (Join-Path $testCloneRoot $relativeRedirectedInCloneFile) -Value "redirected in clone content" -Force + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToExclude', Justification = 'False positive.')] + $testFilesToExclude = @( + @{ destinationFullPath = (Join-Path $testWorkspaceFolder $relativeLegitFile) } + @{ destinationFullPath = (Join-Path $testWorkspaceFolder $relativeRedirectedInWorkspaceFile) } + @{ destinationFullPath = (Join-Path $testWorkspaceFolder $relativeRedirectedInCloneFile) } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update Y -updateBranch "update-al-go-system-files" -token $fakeToken -directCommit $true -actor "test-actor" + + $removeFiles.Count | Should -Be 3 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping removal of '*redirectedInClone*redirected.txt'*" } + + # The legitimate file and the in workspace redirected file were actually removed from the (mocked) clone root; the in clonde redirected file was not + Test-Path -Path (Join-Path $testCloneRoot $relativeLegitFile) -PathType Leaf | Should -Be $false + Test-Path -Path (Join-Path $testCloneRoot $relativeRedirectedInWorkspaceFile) -PathType Leaf | Should -Be $false + Test-Path -Path (Join-Path $testCloneRoot $relativeRedirectedInCloneFile) -PathType Leaf | Should -Be $true + } + + It 'CheckForUpdates skips destination files that do not physically resolve to themselves when removing files using symlinks' -Skip:(-not $script:hasSymlinkCapability) { + $relativeLegitFile = "legit.txt" + $relativeRedirectedInWorkspaceFolder = "redirectedInWorkspace" + $relativeRedirectedInWorkspaceFile = Join-Path $relativeRedirectedInWorkspaceFolder "redirected.txt" + $relativeRedirectedInCloneFolder = "redirectedInClone" + $relativeRedirectedInCloneFile = Join-Path $relativeRedirectedInCloneFolder "redirected.txt" + + # Create folders, files and junctions in workspace folder + Set-Content -Path (Join-Path $testWorkspaceFolder $relativeLegitFile) -Value "legit content" -Force + New-Item -ItemType SymbolicLink -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInWorkspaceFolder) -Target $testWorkspaceFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInCloneFolder) -Force | Out-Null + Set-Content -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInWorkspaceFile) -Value "redirected in workspace content" -Force + Set-Content -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInCloneFile) -Value "redirected in clone content" -Force + + # Create folders, files and junctions in clone folder + Set-Content -Path (Join-Path $testCloneRoot $relativeLegitFile) -Value "legit content" -Force + New-Item -ItemType Directory -Path (Join-Path $testCloneRoot $relativeRedirectedInWorkspaceFolder) -Force | Out-Null + New-Item -ItemType SymbolicLink -Path (Join-Path $testCloneRoot $relativeRedirectedInCloneFolder) -Target $testCloneRoot -Force | Out-Null + Set-Content -Path (Join-Path $testCloneRoot $relativeRedirectedInWorkspaceFile) -Value "redirected in workspace content" -Force + Set-Content -Path (Join-Path $testCloneRoot $relativeRedirectedInCloneFile) -Value "redirected in clone content" -Force + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToExclude', Justification = 'False positive.')] + $testFilesToExclude = @( + @{ destinationFullPath = (Join-Path $testWorkspaceFolder $relativeLegitFile) } + @{ destinationFullPath = (Join-Path $testWorkspaceFolder $relativeRedirectedInWorkspaceFile) } + @{ destinationFullPath = (Join-Path $testWorkspaceFolder $relativeRedirectedInCloneFile) } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update Y -updateBranch "update-al-go-system-files" -token $fakeToken -directCommit $true -actor "test-actor" + + $removeFiles.Count | Should -Be 3 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping removal of '*redirectedInClone*redirected.txt'*" } + + # The legitimate file and the in workspace redirected file were actually removed from the (mocked) clone root; the in clonde redirected file was not + Test-Path -Path (Join-Path $testCloneRoot $relativeLegitFile) -PathType Leaf | Should -Be $false + Test-Path -Path (Join-Path $testCloneRoot $relativeRedirectedInWorkspaceFile) -PathType Leaf | Should -Be $false + Test-Path -Path (Join-Path $testCloneRoot $relativeRedirectedInCloneFile) -PathType Leaf | Should -Be $true } } @@ -250,6 +545,15 @@ Describe "YamlClass Tests" { Mock Trace-Information {} } + It 'Loads only the literal YAML name containing brackets' { + . (Join-Path $scriptRoot 'yamlclass.ps1') + $literalFile = Join-Path $TestDrive 'Workflow[1].yaml' + Set-Content -LiteralPath $literalFile -Value 'name: Correct' + Set-Content -LiteralPath (Join-Path $TestDrive 'Workflow1.yaml') -Value 'name: Wrong' + + [Yaml]::Load($literalFile).content | Should -Be 'name: Correct' + } + It 'Test YamlClass' { . (Join-Path $scriptRoot "yamlclass.ps1") $yaml = [Yaml]::load((Join-Path $PSScriptRoot 'YamlSnippet.txt')) @@ -508,6 +812,18 @@ Describe "CheckForUpdates Action: CheckForUpdates.HelperFunctions.ps1" { $modifiedContent."`$schema" | Should -Be "someSchema" } + It 'GetModifiedSettingsContent reads a literal bracketed destination' { + $source = Join-Path $TestDrive 'SettingsSource.json' + $destination = Join-Path $TestDrive 'Settings[1].json' + Set-Content -LiteralPath $source -Value '{"setting":"source"}' + Set-Content -LiteralPath $destination -Value '{"setting":"destination"}' + Set-Content -LiteralPath (Join-Path $TestDrive 'Settings1.json') -Value '{"setting":"wrong"}' + + $modifiedContent = GetModifiedSettingsContent -srcSettingsFile $source -dstSettingsFile $destination | ConvertFrom-Json + + $modifiedContent.setting | Should -Be 'destination' + } + It 'GetModifiedSettingsContent returns correct content when destination file is empty' { # Create only the source file @{ "`$schema" = "someSchema"; "srcSetting" = "value1" } | ConvertTo-Json -Depth 10 | Out-File -FilePath $tmpSrcFile -Force diff --git a/Tests/GitHub-Helper.Test.ps1 b/Tests/GitHub-Helper.Test.ps1 index 431969d16b..445c9f574f 100644 --- a/Tests/GitHub-Helper.Test.ps1 +++ b/Tests/GitHub-Helper.Test.ps1 @@ -6,6 +6,27 @@ Describe "GitHub-Helper Tests" { . (Join-Path $PSScriptRoot '../Actions/AL-Go-Helper.ps1') } + It 'Get-ContentLF reads a literal path containing wildcard characters' { + $literalFile = Join-Path $TestDrive 'settings[1].json' + $wildcardMatch = Join-Path $TestDrive 'settings1.json' + Set-Content -LiteralPath $literalFile -Value "correct content" + Set-Content -LiteralPath $wildcardMatch -Value "wrong content" + + Get-ContentLF -path $literalFile | Should -Be "correct content" + } + + It 'Set-ContentLF writes to a literal path containing wildcard characters' { + $literalFile = Join-Path $TestDrive 'settings[1].json' + $wildcardMatch = Join-Path $TestDrive 'settings1.json' + Set-Content -LiteralPath $literalFile -Value "unchanged content" + Set-Content -LiteralPath $wildcardMatch -Value "unchanged content" + + Set-ContentLF -path $literalFile -content "changed content" + + Get-ContentLF -path $literalFile | Should -Be "changed content" + Get-ContentLF -path $wildcardMatch | Should -Be "unchanged content" + } + It 'SemVerStrToSemVerObj/SemVerObjToSemVerStr' { { SemVerStrToSemVerObj -semVerStr 'not semver' } | Should -Throw { SemVerStrToSemVerObj -semVerStr '' } | Should -Throw From a1a20dcbbbf6ad02f3ffccfc7f2c94f5012cf8c8 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann <44057549+OleWunschmann@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:15:05 +0200 Subject: [PATCH 30/34] Fix typo in comment for clarity Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Tests/CheckForUpdates.Action.Test.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 370e8293e3..f947a875af 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -490,7 +490,7 @@ Describe "CheckForUpdates Action: runtime behavior" { $removeFiles.Count | Should -Be 3 Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping removal of '*redirectedInClone*redirected.txt'*" } - # The legitimate file and the in workspace redirected file were actually removed from the (mocked) clone root; the in clonde redirected file was not + # The legitimate file and the in workspace redirected file were actually removed from the (mocked) clone root; the in clone redirected file was not Test-Path -Path (Join-Path $testCloneRoot $relativeLegitFile) -PathType Leaf | Should -Be $false Test-Path -Path (Join-Path $testCloneRoot $relativeRedirectedInWorkspaceFile) -PathType Leaf | Should -Be $false Test-Path -Path (Join-Path $testCloneRoot $relativeRedirectedInCloneFile) -PathType Leaf | Should -Be $true From 0b9dc8c815bee8422119ba4ff0a70a8f3bfa0bcc Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Sat, 26 Sep 2026 00:39:28 +0200 Subject: [PATCH 31/34] fix review comments --- .../CheckForUpdates.HelperFunctions.ps1 | 2 +- Scenarios/settings.md | 2 +- Tests/CheckForUpdates.Action.Test.ps1 | 22 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 82ab8f8a68..cf8cf47d3d 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -532,7 +532,7 @@ function GetWorkflowContentWithChangesFromSettings { } # Re-read settings and this time include workflow specific settings - $repoSettings = ReadSettings -buildMode '' -project '' -workflowName $workflowName -userName '' -branchName '' | ConvertTo-HashTable -recurse + $repoSettings = ReadSettings -buildMode '' -project '' -workflowName $workflowName -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse # Old Schedule key is deprecated, but still supported $oldWorkflowScheduleKey = "$($baseName)Schedule" diff --git a/Scenarios/settings.md b/Scenarios/settings.md index 537a982863..a9095099a2 100644 --- a/Scenarios/settings.md +++ b/Scenarios/settings.md @@ -189,7 +189,7 @@ to your [project settings file](#where-are-the-settings-located) will ensure tha - **users** settings will be applied for users matching the patterns - **triggers** settings will be applied when `GITHUB_EVENT_NAME` matches values (for example `push`, `pull_request`, `schedule`, `workflow_dispatch`) -When updating AL-Go System Files, settings are read without a build mode, project, workflow, user, branch, or trigger context. Conditions on any of those six contexts do not apply to the update, including conditions combined with a repository match. Unconditional settings and conditions matching the repository still apply. +When updating AL-Go System Files, settings for selecting files are read without a build mode, project, workflow, user, branch, or trigger context. When generating each workflow, settings are read again with that workflow's name, so workflow-specific settings and conditions on that workflow apply. Neither read uses the event that triggered the update. Unconditional settings and conditions matching the repository still apply; conditions on other execution contexts do not apply. **Note:** You can use `workflowDefaultInputs` within conditional settings to apply workflow input defaults only when certain conditions are met. For example, you could set different default values for specific workflows or branches. diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index f947a875af..7cb2b53d48 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -4059,6 +4059,28 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } +Describe "GetWorkflowContentWithChangesFromSettings" { + BeforeAll { + $scriptRoot = Join-Path $PSScriptRoot '..\Actions\CheckForUpdates' -Resolve + . (Join-Path $scriptRoot 'yamlclass.ps1') + . (Join-Path $scriptRoot 'CheckForUpdates.HelperFunctions.ps1') + } + + It 'Reads target workflow settings without the update event trigger' { + $srcFile = Join-Path $TestDrive 'Sample.yaml' + Set-Content -LiteralPath $srcFile -Value 'name: Sample Workflow' -Encoding UTF8 + Mock ReadSettings { [pscustomobject]@{ 'runs-on' = 'windows-latest'; shell = 'powershell' } } + + GetWorkflowContentWithChangesFromSettings -srcFile $srcFile -repoSettings @{} -depth 1 | Out-Null + + Should -Invoke ReadSettings -Exactly 1 -ParameterFilter { + $buildMode -ceq '' -and $project -ceq '' -and $workflowName -ceq 'Sample Workflow' -and + $userName -ceq '' -and $branchName -ceq '' -and $trigger -ceq '' -and + ($null -eq $repoName -or $repoName -ceq $env:GITHUB_REPOSITORY) + } + } +} + Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { BeforeAll { $actionName = "CheckForUpdates" From 60a175c4675e0f8e6851c2d18bc39d2b3e4de110 Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Wed, 30 Sep 2026 21:43:17 +0200 Subject: [PATCH 32/34] Fix review comments --- .../CheckForUpdates.HelperFunctions.ps1 | 69 +++++++++++-------- Actions/CheckForUpdates/CheckForUpdates.ps1 | 6 +- Scenarios/CustomizingALGoForGitHub.md | 5 +- Tests/CheckForUpdates.Action.Test.ps1 | 68 +++++++++++++++--- 4 files changed, 105 insertions(+), 43 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index f393c8301a..32ab1ae2f0 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -510,7 +510,8 @@ function GetWorkflowContentWithChangesFromSettings { Param( [string] $srcFile, [hashtable] $repoSettings, - [int] $depth + [int] $depth, + [string] $customTemplateFolder = '' ) $baseName = [System.IO.Path]::GetFileNameWithoutExtension($srcFile) @@ -532,7 +533,12 @@ function GetWorkflowContentWithChangesFromSettings { } # Re-read settings and this time include workflow specific settings - $repoSettings = ReadSettings -buildMode '' -project '' -workflowName $workflowName -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse + if ($customTemplateFolder) { + $repoSettings = ReadSettingsWithCurrentCustomTemplateRepoSettings -customTemplateFolder $customTemplateFolder -workflowName $workflowName + } + else { + $repoSettings = ReadSettings -buildMode '' -project '' -workflowName $workflowName -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse + } # Old Schedule key is deprecated, but still supported $oldWorkflowScheduleKey = "$($baseName)Schedule" @@ -1122,7 +1128,12 @@ function ResolveFilePaths { # All files are relative to the template folder OutputDebug "Resolving files for source folder '$($file.sourceFolder)' and filter '$($file.filter)'" - $sourceFiles = @(Get-ChildItem -Path (Join-Path $sourceFolder $file.sourceFolder) -Filter $file.filter -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName) + $fileSourceFolder = Join-Path $sourceFolder $file.sourceFolder + if (-not (Test-PathLexicallyContained -Path $fileSourceFolder -RootFolder $sourceFolder)) { + OutputDebug "Skipping source folder '$fileSourceFolder' as it is not under the source folder '$sourceFolder'." + continue + } + $sourceFiles = @(Get-ChildItem -LiteralPath $fileSourceFolder -Filter $file.filter -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName) OutputDebug "Found $($sourceFiles.Count) files for filter '$($file.filter)' in folder '$($file.sourceFolder)' (relative to folder '$sourceFolder', origin '$($file.origin)')" @@ -1311,55 +1322,57 @@ function GetDefaultFilesToExclude { Temporarily refreshes the custom template repository settings snapshot, reads the merged settings, and restores the snapshot to its original state. This allows the current template settings to affect the current run while preserving the workspace state for the normal update comparison. - Both copy endpoints (the snapshot file under baseFolder and the settings file under templateFolder) are + Both copy endpoints (the snapshot file under baseFolder and the settings file under customTemplateFolder) are validated to physically resolve to themselves before either is read or written; the function throws if a symlink/junction anywhere along either path would redirect the backup, copy or restore to a different physical location .PARAMETER baseFolder - The base folder of the repository whose settings are read. -.PARAMETER templateFolder + The base folder of the repository whose settings are read. Defaults to GITHUB_WORKSPACE. +.PARAMETER customTemplateFolder The folder where the custom template files are located. +.PARAMETER workflowName + The workflow whose settings should be included, if specified. #> function ReadSettingsWithCurrentCustomTemplateRepoSettings { Param( + [string] $baseFolder = "$ENV:GITHUB_WORKSPACE", [Parameter(Mandatory=$true)] - [string] $baseFolder, - [Parameter(Mandatory=$true)] - [string] $templateFolder + [string] $customTemplateFolder, + [string] $workflowName = '' ) - $templateFolderRepoSettingsPath = Join-Path $templateFolder $RepoSettingsFile - $baseFolderTemplateSettingsPath = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $customTemplateRepoSettingsPath = Join-Path $customTemplateFolder $RepoSettingsFile + $baseFolderCustomTemplateSettingsPath = Join-Path $baseFolder $CustomTemplateRepoSettingsFile # Validate both copy endpoints before touching them (even if the leaf file doesn't exist yet): a symlink/junction # anywhere along either path can make it resolve to a different physical location than its literal path - if (-not (Test-PathPhysicallyEqual -Path $baseFolderTemplateSettingsPath -AnchorPaths @($baseFolder))) { - throw "Cannot read settings: '$baseFolderTemplateSettingsPath' does not physically resolve to itself. This may indicate a symlink/junction redirect." + if (-not (Test-PathPhysicallyEqual -Path $baseFolderCustomTemplateSettingsPath -AnchorPaths @($baseFolder))) { + throw "Cannot read settings: '$baseFolderCustomTemplateSettingsPath' does not physically resolve to itself. This may indicate a symlink/junction redirect." } - if (-not (Test-PathPhysicallyEqual -Path $templateFolderRepoSettingsPath -AnchorPaths @($templateFolder))) { - throw "Cannot read settings: '$templateFolderRepoSettingsPath' does not physically resolve to itself. This may indicate a symlink/junction redirect." + if (-not (Test-PathPhysicallyEqual -Path $customTemplateRepoSettingsPath -AnchorPaths @($customTemplateFolder))) { + throw "Cannot read settings: '$customTemplateRepoSettingsPath' does not physically resolve to itself. This may indicate a symlink/junction redirect." } - $baseFolderTemplateSettingsBackupPath = $null + $baseFolderCustomTemplateSettingsBackupPath = $null - if (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { - $baseFolderTemplateSettingsBackupPath = Join-Path (GetTemporaryPath) ([Guid]::NewGuid().ToString()) - Copy-Item -LiteralPath $baseFolderTemplateSettingsPath -Destination $baseFolderTemplateSettingsBackupPath -Force + if (Test-Path -LiteralPath $baseFolderCustomTemplateSettingsPath -PathType Leaf) { + $baseFolderCustomTemplateSettingsBackupPath = Join-Path (GetTemporaryPath) ([Guid]::NewGuid().ToString()) + Copy-Item -LiteralPath $baseFolderCustomTemplateSettingsPath -Destination $baseFolderCustomTemplateSettingsBackupPath -Force } try { - if (Test-Path -LiteralPath $templateFolderRepoSettingsPath -PathType Leaf) { - Copy-Item -LiteralPath $templateFolderRepoSettingsPath -Destination $baseFolderTemplateSettingsPath -Force + if (Test-Path -LiteralPath $customTemplateRepoSettingsPath -PathType Leaf) { + Copy-Item -LiteralPath $customTemplateRepoSettingsPath -Destination $baseFolderCustomTemplateSettingsPath -Force } - # Match the initial read: system-file selection must not depend on the current execution context. - return ReadSettings -baseFolder $baseFolder -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse + # Keep execution-specific contexts empty for both file selection and workflow-specific reads. + return ReadSettings -baseFolder $baseFolder -buildMode '' -project '' -workflowName $workflowName -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse } finally { - if ($baseFolderTemplateSettingsBackupPath) { - Copy-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Destination $baseFolderTemplateSettingsPath -Force - Remove-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Force + if ($baseFolderCustomTemplateSettingsBackupPath) { + Copy-Item -LiteralPath $baseFolderCustomTemplateSettingsBackupPath -Destination $baseFolderCustomTemplateSettingsPath -Force + Remove-Item -LiteralPath $baseFolderCustomTemplateSettingsBackupPath -Force } - elseif (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { - Remove-Item -LiteralPath $baseFolderTemplateSettingsPath -Force + elseif (Test-Path -LiteralPath $baseFolderCustomTemplateSettingsPath -PathType Leaf) { + Remove-Item -LiteralPath $baseFolderCustomTemplateSettingsPath -Force } } } diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index 222b8d170e..27a9e6da3c 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -117,7 +117,7 @@ $baseFolder = $ENV:GITHUB_WORKSPACE if ($originalTemplateFolder) { # Use current custom template settings for this run without changing the workspace before comparison. - $repoSettings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder + $repoSettings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } $projects = @(GetProjectsFromRepository -baseFolder $baseFolder -projectsFromSettings $repoSettings.projects) @@ -142,8 +142,10 @@ if ($projects.Count -gt 1) { # Prepare the list of template folders to be used for verification $templateFolders = @($templateFolder) +$customTemplateFolder = '' if ($originalTemplateFolder) { $templateFolders += $originalTemplateFolder + $customTemplateFolder = $templateFolder } # Loop through all folders in CheckFiles and check if there are any files that needs to be updated @@ -184,7 +186,7 @@ foreach($fileToInclude in $filesToInclude) { switch ($type) { "workflow" { # For workflow files, we might need to modify the file based on the settings - $srcContent = GetWorkflowContentWithChangesFromSettings -srcFile $originalSrcPath -repoSettings $repoSettings -depth $depth + $srcContent = GetWorkflowContentWithChangesFromSettings -srcFile $originalSrcPath -repoSettings $repoSettings -depth $depth -customTemplateFolder $customTemplateFolder # Replace static placeholders $srcContent = $srcContent.Replace('{TEMPLATEURL}', $templateUrl) } diff --git a/Scenarios/CustomizingALGoForGitHub.md b/Scenarios/CustomizingALGoForGitHub.md index b8c7c581e3..5e8a796491 100644 --- a/Scenarios/CustomizingALGoForGitHub.md +++ b/Scenarios/CustomizingALGoForGitHub.md @@ -235,7 +235,7 @@ In order to instruct AL-Go which files to look for at the template repository, y `filesToInclude`, as the name suggests, is an array of file configurations that will instruct AL-Go which files to include (create/update). Every item in the array may contain the following properties: -- `sourceFolder`: A path to a folder, relative to the template, where to look for files. If not specified the root folder is implied. `*` characters are not supported. _Example_: `src/scripts`. +- `sourceFolder`: A literal path to a folder, relative to the template, where to look for files. If not specified the root folder is implied. Wildcards are not supported; characters such as `[` and `]` are treated literally. _Example_: `src/scripts`. - `filter`: A string to use for filtering in the specified source path. It can contain `*` and `?` wildcards. _Example_: `*.ps1` or `fileToUpdate.ps1`. - `destinationFolder`: A path to a folder, relative to repository that is being updated, where the files should be placed. If not specified, defaults to the same as the source file folder. _Example_: `src/templateScripts`. - `perProject`: A boolean that indicates whether the matched files should be propagated for all available AL-Go projects. In that case, `destinationFolder` is relative to the project folder. _Example_: `.AL-Go/scripts`. @@ -247,7 +247,8 @@ In order to instruct AL-Go which files to look for at the template repository, y When using a custom template repository, `filesToInclude` also resolves files from the **original** AL-Go template (i.e. the official [AL-Go-PTE](https://github.com/microsoft/AL-Go-PTE) or [AL-Go-AppSource](https://github.com/microsoft/AL-Go-AppSource) template). This means files present in the official AL-Go template that are not overridden by your custom template are still propagated to consumer repositories. When a file exists in both the original template and your custom template, how the file's **content** is resolved depends on the file's type: - **Workflow files** (`.github/workflows/*.yaml`/`*.yml`): the content is based on the original template's file, with customizations from your custom template's copy (see [Adding custom jobs](#adding-custom-jobs)) re-applied on top. -- **Settings files** and **all other files** (e.g. PowerShell scripts, `.copy.md`, `.agent.md`): the original template's file content is used as-is; changes made to that same file in your custom template are not applied in this case. +- **Settings files**: if the destination file already exists, its settings are preserved and only its `$schema` is updated from the original template. If it does not exist, the original template's settings are used. Current custom template repository settings are also read when updating workflows. +- **All other files** (e.g. PowerShell scripts, `.copy.md`, `.agent.md`): the original template's file content is used as-is; changes made to that same file in your custom template are not applied in this case. The following table summarizes how `filesToInclude` resolves files when a custom template is in use: diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 08710e0a6f..37d8387305 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -2194,6 +2194,25 @@ Describe "ResolveFilePaths" { $fullFilePaths[4].type | Should -Be "markdown" } + It 'ResolveFilePaths treats brackets in file sourceFolder literally' { + $bracketFolder = Join-Path $sourceFolder 'folder[1]' + $bracketFile = Join-Path $bracketFolder 'File.txt' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + try { + New-Item -Path $bracketFolder -ItemType Directory -Force | Out-Null + Set-Content -LiteralPath $bracketFile -Value 'literal folder' + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -destinationFolder $destinationFolder -files @(@{ sourceFolder = 'folder[1]'; filter = '*.txt' })) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be $bracketFile + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'folder[1]/File.txt') + } + finally { + Remove-Item -LiteralPath $bracketFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + It 'ResolveFilePaths skips source files lexically escaping outside the source folder' { $externalFolder = Join-Path $PSScriptRoot "external" $externalFile = Join-Path $externalFolder "outside.txt" @@ -4149,6 +4168,7 @@ Describe "GetWorkflowContentWithChangesFromSettings" { BeforeAll { $scriptRoot = Join-Path $PSScriptRoot '..\Actions\CheckForUpdates' -Resolve . (Join-Path $scriptRoot 'yamlclass.ps1') + . (Join-Path -Path $scriptRoot -ChildPath '..\AL-Go-Helper.ps1' -Resolve) . (Join-Path $scriptRoot 'CheckForUpdates.HelperFunctions.ps1') } @@ -4165,6 +4185,32 @@ Describe "GetWorkflowContentWithChangesFromSettings" { ($null -eq $repoName -or $repoName -ceq $env:GITHUB_REPOSITORY) } } + + It 'Uses current custom template settings for workflow generation and restores the snapshot' { + $baseFolder = Join-Path $TestDrive 'workflowBase' + $templateFolder = Join-Path $TestDrive 'workflowTemplate' + New-Item -ItemType Directory -Path (Join-Path $baseFolder '.github') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $templateFolder '.github') -Force | Out-Null + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $snapshotContent = '{"runs-on":"windows-latest","shell":"powershell"}' + Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $templateFolder $RepoSettingsFile) -Value '{"runs-on":"ubuntu-latest","shell":"pwsh"}' -Encoding UTF8 + $srcFile = Join-Path $TestDrive 'Sample.yaml' + Set-Content -LiteralPath $srcFile -Value @('name: Sample Workflow', 'jobs:', ' Sample:', ' runs-on: [ windows-latest ]', ' steps:', ' - run: echo sample', ' shell: powershell') -Encoding UTF8 + + $originalWorkspace = $env:GITHUB_WORKSPACE + try { + $env:GITHUB_WORKSPACE = $baseFolder + $content = GetWorkflowContentWithChangesFromSettings -srcFile $srcFile -repoSettings @{} -depth 1 -customTemplateFolder $templateFolder + + $content | Should -Match 'runs-on: \[ ubuntu-latest \]' + $content | Should -Match 'shell: pwsh' + Get-ContentLF -Path $snapshotFile | Should -Be $snapshotContent + } + finally { + $env:GITHUB_WORKSPACE = $originalWorkspace + } + } } Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { @@ -4190,7 +4236,7 @@ Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 $snapshotHash = (Get-FileHash -LiteralPath $snapshotFile).Hash - $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder + $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder $settings.customALGoFiles.filesToInclude.Count | Should -Be 1 $settings.customALGoFiles.filesToInclude[0].filter | Should -Be "current.txt" @@ -4208,7 +4254,7 @@ Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { $originalGitHubRepository = $env:GITHUB_REPOSITORY try { $env:GITHUB_REPOSITORY = 'contoso/context-policy-test' - ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder | Out-Null + ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder | Out-Null Should -Invoke ReadSettings -Exactly 1 -ParameterFilter { $buildMode -ceq '' -and $project -ceq '' -and $workflowName -ceq '' -and @@ -4233,7 +4279,7 @@ Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile Test-Path -LiteralPath $snapshotFile | Should -Be $false - $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder + $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder $settings.customALGoFiles.filesToInclude[0].filter | Should -Be "current.txt" Test-Path -LiteralPath $snapshotFile | Should -Be $false @@ -4250,7 +4296,7 @@ Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 $snapshotHash = (Get-FileHash -LiteralPath $snapshotFile).Hash - $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder + $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder $settings.customALGoFiles.filesToInclude[0].filter | Should -Be "existing.txt" (Get-FileHash -LiteralPath $snapshotFile).Hash | Should -Be $snapshotHash @@ -4269,7 +4315,7 @@ Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { $snapshotContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"stale.txt"}]}}' Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 - { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw Get-ContentLF -Path $snapshotFile | Should -Be $snapshotContent } @@ -4291,7 +4337,7 @@ Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { New-Item -ItemType SymbolicLink -Path (Join-Path $baseFolder ".github") -Target $externalGithubFolder -Force | Out-Null - { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw Get-ContentLF -Path $externalSnapshotFile | Should -Be $externalSnapshotContent } @@ -4312,7 +4358,7 @@ Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile New-Item -ItemType SymbolicLink -Path $snapshotFile -Target $externalTargetFile -Force | Out-Null - { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw Get-ContentLF -Path $externalTargetFile | Should -Be $externalTargetContent } @@ -4331,7 +4377,7 @@ Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { New-Item -ItemType SymbolicLink -Path (Join-Path $templateFolder ".github") -Target $externalGithubFolder -Force | Out-Null - { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw Get-ContentLF -Path $externalSettingsFile | Should -Be $externalSettingsContent } @@ -4353,7 +4399,7 @@ Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { $snapshotContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"stale.txt"}]}}' Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 - { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw Get-ContentLF -Path $snapshotFile | Should -Be $snapshotContent Get-ContentLF -Path $externalTargetFile | Should -Be $externalTargetContent @@ -4376,7 +4422,7 @@ Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { New-Item -ItemType Junction -Path (Join-Path $baseFolder ".github") -Target $externalGithubFolder -Force | Out-Null - { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw Get-ContentLF -Path $externalSnapshotFile | Should -Be $externalSnapshotContent } @@ -4395,7 +4441,7 @@ Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { New-Item -ItemType Junction -Path (Join-Path $templateFolder ".github") -Target $externalGithubFolder -Force | Out-Null - { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw Get-ContentLF -Path $externalSettingsFile | Should -Be $externalSettingsContent } From f9f9eb13b3927342f0c5bf5df0146b253875210d Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Wed, 30 Sep 2026 23:41:32 +0200 Subject: [PATCH 33/34] Fix review comments --- .../CheckForUpdates.HelperFunctions.ps1 | 38 +++-- Tests/CheckForUpdates.Action.Test.ps1 | 153 ++++++++++-------- e2eTests/scenarios/CustomTemplate/runtest.ps1 | 12 +- 3 files changed, 118 insertions(+), 85 deletions(-) diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 32ab1ae2f0..2e05d6c43d 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -1090,10 +1090,7 @@ function ResolveFilePaths { $sourceFolder = Resolve-PathLexically -Path $sourceFolder -AsDirectory $destinationFolder = Resolve-PathLexically -Path $destinationFolder -AsDirectory - $pathComparer = GetPathStringComparer - $fullFilePaths = @() - $destinationFullPaths = [System.Collections.Generic.HashSet[string]]::new($pathComparer) foreach($file in $files) { if($file.Keys -notcontains 'sourceFolder') { $file.sourceFolder = '' # Default to current folder @@ -1224,10 +1221,6 @@ function ResolveFilePaths { continue } - if(-not $destinationFullPaths.Add($fullProjectFilePath.destinationFullPath)) { - OutputDebug "Skipping duplicate per-project file for project '$project': destinationFullPath '$($fullProjectFilePath.destinationFullPath)' already exists" - continue - } OutputDebug "Adding per-project file for project '$project': sourceFullPath '$($fullProjectFilePath.sourceFullPath)', originalSourceFullPath '$($fullProjectFilePath.originalSourceFullPath)', destinationFullPath '$($fullProjectFilePath.destinationFullPath)'" $fullFilePaths += $fullProjectFilePath } @@ -1254,10 +1247,6 @@ function ResolveFilePaths { continue } - if(-not $destinationFullPaths.Add($fullFilePath.destinationFullPath)) { - OutputDebug "Skipping duplicate file: destinationFullPath '$($fullFilePath.destinationFullPath)' already exists" - continue - } OutputDebug "Adding file: sourceFullPath '$($fullFilePath.sourceFullPath)', originalSourceFullPath '$($fullFilePath.originalSourceFullPath)', destinationFullPath '$($fullFilePath.destinationFullPath)'" $fullFilePaths += $fullFilePath } @@ -1448,9 +1437,6 @@ function GetFilesToUpdate { if ($hasOriginalTemplate) { $filesToInclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) } - # Deduplicate files to include based on destinationFullPath, keeping the first one (default > settings; template folder > original template folder) - $filesToIncludeDestinationFullPaths = [System.Collections.Generic.HashSet[string]]::new($pathComparer) - $filesToInclude = @($filesToInclude | Where-Object { $filesToIncludeDestinationFullPaths.Add($_.destinationFullPath) }) # Determine files to exclude $filesToExcludeUnresolved = GetDefaultFilesToExclude -settings $settings @@ -1459,8 +1445,6 @@ function GetFilesToUpdate { if ($hasOriginalTemplate) { $filesToExclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) } - # filesToExclude is not deduplicated by destinationFullPath here. - # Its destinationFullPath is never part of the actual output; only sourceFullPath is used below to match against filesToInclude. # Map files from filesToExclude to files that are in filesToInclude (based on source) # Settings for filesToExclude only define the sources (sourceFolder and filter) but not the destinations (destinationFolder, destinationName and perProject) @@ -1494,6 +1478,28 @@ function GetFilesToUpdate { $filesToExclude += @($unusedFilesToExclude) } + # Deduplicate files to include based on destinationFullPath, keeping the first one (default > settings; template folder > original template folder) + $filesToIncludeDestinationFullPaths = [System.Collections.Generic.HashSet[string]]::new($pathComparer) + $filesToInclude = @($filesToInclude | Where-Object { + $include = $filesToIncludeDestinationFullPaths.Add($_.destinationFullPath) + if (-not $include) { OutputDebug "Skipping duplicate file to include '$($_.sourceFullPath)': destinationFullPath '$($_.destinationFullPath)' already included" } + return $include + }) + + # Exclude files from filesToExclude that are still included in filesToInclude based on destinationFullPath + $filesToExclude = @($filesToExclude | Where-Object { + $exclude = -not $filesToIncludeDestinationFullPaths.Contains($_.destinationFullPath) + if (-not $exclude) { OutputDebug "Skipping file to exclude '$($_.sourceFullPath)': destinationFullPath '$($_.destinationFullPath)' included with different source" } + return $exclude + }) + # Deduplicate files to exclude based on destinationFullPath, keeping the first one (default > settings; template folder > original template folder) + $filesToExcludeDestinationFullPaths = [System.Collections.Generic.HashSet[string]]::new($pathComparer) + $filesToExclude = @($filesToExclude | Where-Object { + $exclude = $filesToExcludeDestinationFullPaths.Add($_.destinationFullPath) + if (-not $exclude) { OutputDebug "Skipping duplicate file to exclude '$($_.sourceFullPath)': destinationFullPath '$($_.destinationFullPath)' already excluded" } + return $exclude + }) + # List all files to be included and excluded with their source and destination paths, type and original source path (if any) $fileFormatter = { param($file) " -Source: $($file.sourceFullPath), Destination: $($file.destinationFullPath), Type: $($file.type), Original Source: $($file.originalSourceFullPath)"} OutputArray -Message "Files to include: $($filesToInclude.Count)" -Array $filesToInclude -Formatter $fileFormatter diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 37d8387305..2fac887fac 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -2383,7 +2383,7 @@ Describe "ResolveFilePaths" { $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") } - It 'ResolveFilePaths avoids duplicate destination entries' { + It 'ResolveFilePaths retains duplicate destination candidates' { $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" $files = @( @{ "sourceFolder" = "folder"; "filter" = "File1.txt" } @@ -2393,36 +2393,9 @@ Describe "ResolveFilePaths" { $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) $fullFilePaths | Should -Not -BeNullOrEmpty - $fullFilePaths.Count | Should -Be 1 - $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") - } - - It 'ResolveFilePaths keeps case-distinct destination entries on Linux' -Skip:(-not $script:isLinuxPlatform) { - $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' - $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt' } - @{ 'sourceFolder' = 'folder'; 'filter' = 'File2.log'; 'destinationFolder' = 'casefolder'; 'destinationName' = 'conflict.txt' } - ) - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) - $fullFilePaths.Count | Should -Be 2 - ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'CaseFolder/conflict.txt')) | Should -BeTrue - ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'casefolder/conflict.txt')) | Should -BeTrue - } - - It 'ResolveFilePaths removes case-distinct destination entries on Windows' -Skip:(-not $script:isWindowsPlatform) { - $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' - $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt' } - @{ 'sourceFolder' = 'folder'; 'filter' = 'File2.log'; 'destinationFolder' = 'casefolder'; 'destinationName' = 'conflict.txt' } - ) - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) - - $fullFilePaths.Count | Should -Be 1 - ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'CaseFolder/conflict.txt')) | Should -BeTrue - ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'casefolder/conflict.txt')) | Should -BeFalse + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") + $fullFilePaths[1].destinationFullPath | Should -Be $fullFilePaths[0].destinationFullPath } It 'ResolveFilePaths treats dot project as repository root for per-project files' { @@ -2562,7 +2535,7 @@ Describe "ResolveFilePaths" { $fullFilePaths[1].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File3.txt") } - It 'ResolveFilePaths with perProject skips duplicate files across projects' { + It 'ResolveFilePaths with perProject retains duplicate destination candidates' { $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" $files = @( @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; perProject = $true } @@ -2571,38 +2544,10 @@ Describe "ResolveFilePaths" { $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @("ProjectA")) - # Should only have one entry per project since both resolve to the same destination $fullFilePaths | Should -Not -BeNullOrEmpty - $fullFilePaths.Count | Should -Be 1 - $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectA/folder/File1.txt") - } - - It 'ResolveFilePaths keeps case-distinct per-project destination entries on Linux' -Skip:(-not $script:isLinuxPlatform) { - $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' - $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } - @{ 'sourceFolder' = 'folder'; 'filter' = 'File2.log'; 'destinationFolder' = 'casefolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } - ) - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('ProjectA')) - $fullFilePaths.Count | Should -Be 2 - ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/CaseFolder/conflict.txt')) | Should -BeTrue - ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/casefolder/conflict.txt')) | Should -BeTrue - } - - It 'ResolveFilePaths removes case-distinct per-project destination entries on Windows' -Skip:(-not $script:isWindowsPlatform) { - $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' - $files = @( - @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } - @{ 'sourceFolder' = 'folder'; 'filter' = 'File2.log'; 'destinationFolder' = 'casefolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } - ) - - $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('ProjectA')) - - $fullFilePaths.Count | Should -Be 1 - ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/CaseFolder/conflict.txt')) | Should -BeTrue - ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/casefolder/conflict.txt')) | Should -BeFalse + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectA/folder/File1.txt") + $fullFilePaths[1].destinationFullPath | Should -Be $fullFilePaths[0].destinationFullPath } It 'ResolveFilePaths handles empty sourceFolder value' { @@ -2637,10 +2582,9 @@ Describe "ResolveFilePaths" { $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) - # File1.txt should only appear once even though both filters match it $fullFilePaths | Should -Not -BeNullOrEmpty $file1Matches = @($fullFilePaths | Where-Object { $_.sourceFullPath -eq (Join-Path $sourceFolder "folder/File1.txt") }) - $file1Matches.Count | Should -Be 1 + $file1Matches.Count | Should -Be 2 } It 'ResolveFilePaths correctly resolves originalSourceFullPath only when file exists in original folder' { @@ -3903,6 +3847,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } + Mock OutputDebug { } $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # Only one entry should be resolved for the colliding destination @@ -3911,6 +3856,47 @@ Describe "GetFilesToUpdate (general files to update logic)" { # The first-listed entry should win over the later entry for the same destination $conflict[0].sourceFullPath | Should -Be $testPSFile + Should -Invoke OutputDebug -Exactly -Times 1 -ParameterFilter { $message -eq "Skipping duplicate file to include '$testTxtFile': destinationFullPath '$(Join-Path $baseFolder 'conflict.txt')' already included" } + } + + It 'GetFilesToUpdate filesToExclude keeps the first entry when two entries collide on the same destination' { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = 'test.ps1'; destinationName = 'conflict.txt' }, @{ filter = 'test.txt'; destinationName = 'conflict.txt' }) + filesToExclude = @(@{ filter = 'test.ps1' }, @{ filter = 'test.txt' }) + } + } + + Mock OutputDebug { } + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToInclude | Should -BeNullOrEmpty + $filesToExclude.Count | Should -Be 1 + $filesToExclude[0].sourceFullPath | Should -Be $testPSFile + $filesToExclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'conflict.txt') + Should -Invoke OutputDebug -Exactly -Times 1 -ParameterFilter { $message -eq "Skipping duplicate file to exclude '$testTxtFile': destinationFullPath '$(Join-Path $baseFolder 'conflict.txt')' already excluded" } + } + + It 'GetFilesToUpdate retains a replacement when the first source for a destination is excluded' { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = 'test.ps1'; destinationName = 'conflict.txt' }, @{ filter = 'test.txt'; destinationName = 'conflict.txt' }) + filesToExclude = @(@{ filter = 'test.ps1' }) + } + } + + Mock OutputDebug { } + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $conflict = @($filesToInclude | Where-Object { $_.destinationFullPath -eq (Join-Path $baseFolder 'conflict.txt') }) + $conflict.Count | Should -Be 1 + $conflict[0].sourceFullPath | Should -Be (Join-Path $templateFolder 'test.txt') + @($filesToExclude | Where-Object { $_.destinationFullPath -eq (Join-Path $baseFolder 'conflict.txt') }).Count | Should -Be 0 + Should -Invoke OutputDebug -Exactly -Times 1 -ParameterFilter { $message -eq "Skipping file to exclude '$testPSFile': destinationFullPath '$(Join-Path $baseFolder 'conflict.txt')' included with different source" } } It 'GetFilesToUpdate keeps case-distinct destination entries on Linux' -Skip:(-not $script:isLinuxPlatform) { @@ -4016,6 +4002,47 @@ Describe "GetFilesToUpdate (general files to update logic)" { ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'casefolder/conflict.txt')) | Should -BeFalse } + It 'GetFilesToUpdate retains a case-distinct removal when another destination is included on Linux' -Skip:(-not $script:isLinuxPlatform) { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @( + @{ filter = 'test.ps1'; destinationFolder = 'CaseFolder'; destinationName = 'conflict.txt' } + @{ filter = 'test.txt'; destinationFolder = 'casefolder'; destinationName = 'conflict.txt' } + ) + filesToExclude = @(@{ filter = 'test.ps1' }) + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToInclude.Count | Should -Be 1 + $filesToInclude[0].destinationFullPath | Should -BeExactly (Join-Path $baseFolder 'casefolder/conflict.txt') + $filesToExclude.Count | Should -Be 1 + $filesToExclude[0].destinationFullPath | Should -BeExactly (Join-Path $baseFolder 'CaseFolder/conflict.txt') + } + + It 'GetFilesToUpdate suppresses a case-distinct removal when the destination is included on Windows' -Skip:(-not $script:isWindowsPlatform) { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @( + @{ filter = 'test.ps1'; destinationFolder = 'CaseFolder'; destinationName = 'conflict.txt' } + @{ filter = 'test.txt'; destinationFolder = 'casefolder'; destinationName = 'conflict.txt' } + ) + filesToExclude = @(@{ filter = 'test.ps1' }) + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToInclude.Count | Should -Be 1 + $filesToInclude[0].destinationFullPath | Should -BeExactly (Join-Path $baseFolder 'casefolder/conflict.txt') + $filesToExclude | Should -BeNullOrEmpty + } + It 'GetFilesToUpdate excludes any case source path on Windows' -Skip:(-not $script:isWindowsPlatform) { $upperCaseFolder = Join-Path $templateFolder 'CaseFolder' $upperCaseFile = Join-Path $upperCaseFolder 'script.ps1' diff --git a/e2eTests/scenarios/CustomTemplate/runtest.ps1 b/e2eTests/scenarios/CustomTemplate/runtest.ps1 index 93132e89e3..36f65dce0c 100644 --- a/e2eTests/scenarios/CustomTemplate/runtest.ps1 +++ b/e2eTests/scenarios/CustomTemplate/runtest.ps1 @@ -190,10 +190,10 @@ jobs: "@ Set-Content -Path $customWorkflowFile -Value $customWorkflowContent -$finalRepoCustomWorkflowContent = $customWorkflowContent +$expectedCustomWorkflowContent = $customWorkflowContent if($linux) { - $finalRepoCustomWorkflowContent = $finalRepoCustomWorkflowContent -replace 'windows-latest', 'ubuntu-latest' - $finalRepoCustomWorkflowContent = $finalRepoCustomWorkflowContent -replace 'shell: powershell', 'shell: pwsh' + $expectedCustomWorkflowContent = $expectedCustomWorkflowContent -replace 'windows-latest', 'ubuntu-latest' + $expectedCustomWorkflowContent = $expectedCustomWorkflowContent -replace 'shell: powershell', 'shell: pwsh' } # Add custom files in the template repository @@ -245,7 +245,7 @@ Pull # Check that custom workflow file is present (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist -Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $customWorkflowContent.Replace("`r", "").TrimEnd("`n") +Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $expectedCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") # Check that default custom file is present (Join-Path (Get-Location) $defaultCustomFileName) | Should -Exist @@ -390,7 +390,7 @@ Get-ContentLF -Path (Join-Path (Get-Location) $CustomTemplateRepoSettingsFile) | # Check that custom workflow file is present (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist -Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $finalRepoCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") +Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $expectedCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") # Check that default custom file is present (in template's filesToInclude) (Join-Path (Get-Location) $defaultCustomFileName) | Should -Exist @@ -434,7 +434,7 @@ Pull # Check that custom workflow file is present (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist -Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $finalRepoCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") +Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $expectedCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") # Check that default custom file is NOT present (in repo's filesToExclude and template's filesToInclude) (Join-Path (Get-Location) $defaultCustomFileName) | Should -Not -Exist From c54f7571b41d96cb60d66bb9c18ab58fe08bf35a Mon Sep 17 00:00:00 2001 From: Ole Wunschmann Date: Thu, 1 Oct 2026 01:18:26 +0200 Subject: [PATCH 34/34] fix e2e test --- e2eTests/scenarios/CustomTemplate/runtest.ps1 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/e2eTests/scenarios/CustomTemplate/runtest.ps1 b/e2eTests/scenarios/CustomTemplate/runtest.ps1 index 36f65dce0c..93132e89e3 100644 --- a/e2eTests/scenarios/CustomTemplate/runtest.ps1 +++ b/e2eTests/scenarios/CustomTemplate/runtest.ps1 @@ -190,10 +190,10 @@ jobs: "@ Set-Content -Path $customWorkflowFile -Value $customWorkflowContent -$expectedCustomWorkflowContent = $customWorkflowContent +$finalRepoCustomWorkflowContent = $customWorkflowContent if($linux) { - $expectedCustomWorkflowContent = $expectedCustomWorkflowContent -replace 'windows-latest', 'ubuntu-latest' - $expectedCustomWorkflowContent = $expectedCustomWorkflowContent -replace 'shell: powershell', 'shell: pwsh' + $finalRepoCustomWorkflowContent = $finalRepoCustomWorkflowContent -replace 'windows-latest', 'ubuntu-latest' + $finalRepoCustomWorkflowContent = $finalRepoCustomWorkflowContent -replace 'shell: powershell', 'shell: pwsh' } # Add custom files in the template repository @@ -245,7 +245,7 @@ Pull # Check that custom workflow file is present (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist -Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $expectedCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") +Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $customWorkflowContent.Replace("`r", "").TrimEnd("`n") # Check that default custom file is present (Join-Path (Get-Location) $defaultCustomFileName) | Should -Exist @@ -390,7 +390,7 @@ Get-ContentLF -Path (Join-Path (Get-Location) $CustomTemplateRepoSettingsFile) | # Check that custom workflow file is present (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist -Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $expectedCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") +Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $finalRepoCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") # Check that default custom file is present (in template's filesToInclude) (Join-Path (Get-Location) $defaultCustomFileName) | Should -Exist @@ -434,7 +434,7 @@ Pull # Check that custom workflow file is present (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist -Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $expectedCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") +Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $finalRepoCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") # Check that default custom file is NOT present (in repo's filesToExclude and template's filesToInclude) (Join-Path (Get-Location) $defaultCustomFileName) | Should -Not -Exist