diff --git a/.github/workflows/E2E.yaml b/.github/workflows/E2E.yaml index d8905a2f5..dbe82eab2 100644 --- a/.github/workflows/E2E.yaml +++ b/.github/workflows/E2E.yaml @@ -25,6 +25,11 @@ on: description: Run the end to end scenario tests type: boolean default: true + scenariosFilter: + description: Filter to run specific scenarios (separated by comma) + required: false + type: string + default: '*' runUpgradeTests: description: Run the end to end upgrade tests type: boolean @@ -169,6 +174,7 @@ jobs: id: Analyze env: GH_TOKEN: ${{ steps.app-token.outputs.token }} + _scenariosFilter: ${{ github.event.inputs.scenariosFilter }} run: | $errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0 $modulePath = Join-Path "." "e2eTests\e2eTestHelper.psm1" -resolve @@ -222,9 +228,13 @@ jobs: Add-Content -Encoding UTF8 -Path $env:GITHUB_OUTPUT -Value "releases=$releasesJson" Write-Host "releases=$releasesJson" + $scenariosFilter = "$($env:_scenariosFilter)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } + $allScenarios = @(Get-ChildItem -Path (Join-Path $ENV:GITHUB_WORKSPACE "e2eTests/scenarios/*/runtest.ps1") | ForEach-Object { $_.Directory.Name }) + $filteredScenarios = $allScenarios | Where-Object { $scenario = $_; $scenariosFilter | ForEach-Object { $scenario -like $_ } } + $scenariosJson = @{ "matrix" = @{ - "include" = @(Get-ChildItem -path (Join-Path $ENV:GITHUB_WORKSPACE "e2eTests/scenarios/*/runtest.ps1") | ForEach-Object { @{ "Scenario" = $_.Directory.Name } } ) + "include" = @($filteredScenarios | ForEach-Object { @{ "Scenario" = $_ } }) }; "max-parallel" = $maxParallel "fail-fast" = $false diff --git a/Actions/.Modules/DebugLogHelper.psm1 b/Actions/.Modules/DebugLogHelper.psm1 index 335ac82d0..c3f562d2d 100644 --- a/Actions/.Modules/DebugLogHelper.psm1 +++ b/Actions/.Modules/DebugLogHelper.psm1 @@ -214,5 +214,55 @@ function OutputDebug { } } -Export-ModuleMember -Function OutputColor, OutputDebugFunctionCall, OutputGroupStart, OutputGroupEnd, OutputError, OutputWarning, OutputNotice, MaskValueInLog, OutputDebug +<# + .SYNOPSIS + Outputs each item in an array to the console with optional formatting. + .DESCRIPTION + Outputs each item in an array to the console. An optional formatter script block can be provided to customize the output format of each item. + If a message is provided, it is output before the array items. If the array is empty or null, it outputs "- None". + .PARAMETER Message + An optional message to output before the array items. + .PARAMETER Array + The array of items to output. + .PARAMETER Formatter + An optional script block to format each item in the array. + .PARAMETER Debug + A switch indicating whether to output messages as debug messages. +#> +function OutputArray { + Param( + [string] $Message, + [object[]] $Array, + [scriptblock] $Formatter = { "- $_" }, + [switch] $Debug + ) + + function OutputMessage { + Param( + [string] $Message, + [switch] $Debug + ) + + if ($Debug) { + OutputDebug $Message + } + else { + Write-Host $Message + } + } + + if($Message) { + OutputMessage $Message -Debug:$Debug + } + if (!$Array) { + OutputMessage "- None" -Debug:$Debug + } + else { + $Array | ForEach-Object { + OutputMessage "$(& $Formatter $_)" -Debug:$Debug + } + } +} + +Export-ModuleMember -Function OutputColor, OutputDebugFunctionCall, OutputGroupStart, OutputGroupEnd, OutputError, OutputWarning, OutputNotice, MaskValueInLog, OutputDebug, OutputArray Export-ModuleMember -Variable debugLoggingEnabled diff --git a/Actions/.Modules/ReadSettings.psm1 b/Actions/.Modules/ReadSettings.psm1 index 8c80cbb36..3e483e429 100644 --- a/Actions/.Modules/ReadSettings.psm1 +++ b/Actions/.Modules/ReadSettings.psm1 @@ -1,10 +1,17 @@ Import-Module (Join-Path -Path $PSScriptRoot "DebugLogHelper.psm1") $ALGoFolderName = '.AL-Go' -$ALGoSettingsFile = Join-Path '.AL-Go' 'settings.json' -$RepoSettingsFile = Join-Path '.github' 'AL-Go-Settings.json' -$CustomTemplateRepoSettingsFile = Join-Path '.github' 'AL-Go-TemplateRepoSettings.doNotEdit.json' -$CustomTemplateProjectSettingsFile = Join-Path '.github' 'AL-Go-TemplateProjectSettings.doNotEdit.json' +$ALGoSettingsFileName = 'settings.json' +$ALGoSettingsFile = Join-Path '.AL-Go' $ALGoSettingsFileName + +$RepoSettingsFileName = 'AL-Go-Settings.json' +$RepoSettingsFile = Join-Path '.github' $RepoSettingsFileName + +$CustomTemplateRepoSettingsFileName = 'AL-Go-TemplateRepoSettings.doNotEdit.json' +$CustomTemplateRepoSettingsFile = Join-Path '.github' $CustomTemplateRepoSettingsFileName + +$CustomTemplateProjectSettingsFileName = 'AL-Go-TemplateProjectSettings.doNotEdit.json' +$CustomTemplateProjectSettingsFile = Join-Path '.github' $CustomTemplateProjectSettingsFileName function MergeCustomObjectIntoOrderedDictionary { Param( @@ -242,6 +249,10 @@ function GetDefaultSettings "shortLivedArtifactsRetentionDays" = 1 # 0 means use GitHub default "reportSuppressedDiagnostics" = $false "workflowDefaultInputs" = @() + "customALGoFiles" = [ordered]@{ + "filesToInclude" = @() + "filesToExclude" = @() + } } } @@ -610,4 +621,4 @@ function SanitizeWorkflowName { } Export-ModuleMember -Function ReadSettings -Export-ModuleMember -Variable ALGoFolderName, ALGoSettingsFile, RepoSettingsFile, CustomTemplateRepoSettingsFile, CustomTemplateProjectSettingsFile +Export-ModuleMember -Variable ALGoFolderName, ALGoSettingsFile, RepoSettingsFile, CustomTemplateRepoSettingsFile, CustomTemplateProjectSettingsFile, RepoSettingsFileName, ALGoSettingsFileName, CustomTemplateRepoSettingsFileName, CustomTemplateProjectSettingsFileName diff --git a/Actions/.Modules/settings.schema.json b/Actions/.Modules/settings.schema.json index 4e36caa21..bd7180fe0 100644 --- a/Actions/.Modules/settings.schema.json +++ b/Actions/.Modules/settings.schema.json @@ -694,6 +694,52 @@ "type": "boolean", "description": "Report suppressed diagnostics. See https://aka.ms/ALGoSettings#reportsuppresseddiagnostics" }, + "customALGoFiles": { + "description": "An object containing the custom AL-Go files configuration. See https://aka.ms/ALGoSettings#customALGoFiles", + "type": "object", + "properties": { + "filesToInclude": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceFolder": { + "type": "string", + "description": "The source folder from which to update files, relative to the template repository root." + }, + "filter": { + "type": "string", + "description": "A filter string to select which files to update. It can contain '*' and '?' wildcards." + }, + "destinationFolder": { + "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." + }, + "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." + } + } + } + }, + "filesToExclude": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceFolder": { + "type": "string", + "description": "The source folder from which to exclude files, relative to the template repository root." + }, + "filter": { + "type": "string", + "description": "A filter string to select which files to exclude. It can contain '*' and '?' wildcards." + } + } + } + } + } + }, "workflowDefaultInputs": { "type": "array", "items": { diff --git a/Actions/AL-Go-Helper.ps1 b/Actions/AL-Go-Helper.ps1 index 9bd124e56..efd769a75 100644 --- a/Actions/AL-Go-Helper.ps1 +++ b/Actions/AL-Go-Helper.ps1 @@ -2184,6 +2184,12 @@ function ConnectAz { } } +<# + .SYNOPSIS + Output a message and an array of strings in a formatted way. + + Deprecated: Use OutputArray function from DebugLogHelper module. +#> function OutputMessageAndArray { Param( [string] $message, diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 4d264298c..82a8d5d22 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -1,3 +1,6 @@ +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 @@ -61,6 +64,7 @@ function DownloadTemplateRepository { InvokeWebRequest -Headers $headers -Uri $archiveUrl -OutFile "$tempName.zip" Expand-7zipArchive -Path "$tempName.zip" -DestinationPath $tempName Remove-Item -Path "$tempName.zip" + return $tempName } @@ -472,8 +476,7 @@ function GetWorkflowContentWithChangesFromSettings { Param( [string] $srcFile, [hashtable] $repoSettings, - [int] $depth, - [bool] $includeBuildPP + [int] $depth ) $baseName = [System.IO.Path]::GetFileNameWithoutExtension($srcFile) @@ -554,6 +557,7 @@ function GetWorkflowContentWithChangesFromSettings { # 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 } @@ -629,11 +633,9 @@ function GetSrcFolder { [string] $repoType, [string] $templateUrl, [string] $templateFolder, - [string] $srcPath + [string] $srcPath = '' ) - Write-Host $templateUrl - Write-Host $templateFolder - Write-Host $srcPath + if (!$templateUrl) { return '' } @@ -732,3 +734,323 @@ function UpdateSettingsFile { } 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 @() + } + + $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' = '.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 + Get the list of files from the template repository to include and exclude based on the provided settings. +.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. +.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 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. +.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. + - 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 = @() + ) + + 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)" + } + + $filesToInclude = GetDefaultFilesToInclude -includeCustomTemplateFiles:$($null -ne $originalTemplateFolder) + $filesToInclude += $settings.customALGoFiles.filesToInclude + $filesToInclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToInclude -projects $projects) + + $filesToExclude = GetDefaultFilesToExclude -settings $settings + $filesToExclude += $settings.customALGoFiles.filesToExclude + $filesToExclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExclude -projects $projects) + + # 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" + } + return $include + }) + + # Exclude files from filesToInclude that are in filesToExclude + $filesToInclude = @($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 $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 8cf3511f2..46a95da70 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -54,12 +54,6 @@ if ($token) { # 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 $templateSha = $repoSettings.templateSha -$unusedALGoSystemFiles = $repoSettings.unusedALGoSystemFiles -$includeBuildPP = $repoSettings.type -eq 'PTE' -and $repoSettings.powerPlatformSolutionFolder -ne '' -if (!$includeBuildPP) { - # Remove PowerPlatform workflows if no PowerPlatformSolution exists - $unusedALGoSystemFiles += @('_BuildPowerPlatformSolution.yaml','PushPowerPlatformChanges.yaml','PullPowerPlatformChanges.yaml') -} # If templateUrl has changed, download latest version of the template repository (ignore templateSha) if ($repoSettings.templateUrl -ne $templateUrl -or $templateSha -eq '') { @@ -68,6 +62,7 @@ if ($repoSettings.templateUrl -ne $templateUrl -or $templateSha -eq '') { $originalTemplateFolder = $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" $templateBranch = $templateUrl.Split('@')[1] @@ -76,7 +71,7 @@ $templateInfo = "$templateOwner/$($templateUrl.Split('/')[4])" $isDirectALGo = IsDirectALGo -templateUrl $templateUrl if (-not $isDirectALGo) { - $templateRepoSettingsFile = Join-Path $templateFolder "*/$RepoSettingsFile" + $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) { @@ -99,6 +94,8 @@ if (-not $isDirectALGo) { # 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" # Set TemplateBranch and TemplateOwner @@ -106,52 +103,23 @@ if (-not $isDirectALGo) { $templateBranch = $originalTemplateUrl.Split('@')[1] $templateOwner = $originalTemplateUrl.Split('/')[3] - # If the custom template contains unusedALGoSystemFiles, we need to remove them from the current repository - if ($templateRepoSettings.ContainsKey('unusedALGoSystemFiles')) { - $unusedALGoSystemFiles += $templateRepoSettings.unusedALGoSystemFiles + $isDirectALGo = IsDirectALGo -templateUrl $originalTemplateUrl + if ($isDirectALGo) { + Trace-Information -Message "Original template repository is direct AL-Go" } } } } -# CheckFiles is an array of hashtables with the following properties: -# dstPath: The path to the file in the current repository -# srcPath: The path to the file in the template repository -# pattern: The pattern to use when searching for files in the template repository -# type: The type of file (script, workflow, releasenotes) -# The files currently checked are: -# - All files in .github/workflows -# - All files in .github that ends with .copy.md -# - All PowerShell scripts in .AL-Go folders (all projects) -$checkfiles = @( - @{ 'dstPath' = (Join-Path '.github' 'workflows'); 'dstName' = ''; 'srcPath' = (Join-Path '.github' 'workflows'); 'pattern' = '*'; 'type' = 'workflow' }, - @{ 'dstPath' = '.github'; 'dstName' = ''; 'srcPath' = '.github'; 'pattern' = '*.copy.md'; 'type' = 'releasenotes' } - @{ 'dstPath' = '.github'; 'dstName' = ''; 'srcPath' = '.github'; 'pattern' = '*.ps1'; 'type' = 'script' } - @{ 'dstPath' = '.github'; 'dstName' = ''; 'srcPath' = '.github'; 'pattern' = 'AL-Go-Settings.json'; 'type' = 'settings' }, - @{ 'dstPath' = '.github'; 'dstName' = ''; 'srcPath' = '.github'; 'pattern' = '*.settings.json'; 'type' = 'settings' } -) - -if ($originalTemplateFolder) { - $checkfiles += @( - @{ 'dstPath' = ([system.IO.Path]::GetDirectoryName($CustomTemplateRepoSettingsFile)); 'dstName' = ([system.IO.Path]::GetFileName($CustomTemplateRepoSettingsFile)); 'SrcPath' = ([system.IO.Path]::GetDirectoryName($RepoSettingsFile)); 'pattern' = ([system.IO.Path]::GetFileName($RepoSettingsFile)); 'type' = 'template repo settings' } - @{ 'dstPath' = ([system.IO.Path]::GetDirectoryName($CustomTemplateProjectSettingsFile)); 'dstName' = ([system.IO.Path]::GetFileName($CustomTemplateProjectSettingsFile)); 'SrcPath' = ([system.IO.Path]::GetDirectoryName($ALGoSettingsFile)); 'pattern' = ([system.IO.Path]::GetFileName($ALGoSettingsFile)); ; 'type' = 'template project settings' } - ) -} - # Get the list of projects in the current repository $baseFolder = $ENV:GITHUB_WORKSPACE $projects = @(GetProjectsFromRepository -baseFolder $baseFolder -projectsFromSettings $repoSettings.projects) -Write-Host "Projects found: $($projects.Count)" -foreach($project in $projects) { - Write-Host "- $project" - $checkfiles += @( - @{ 'dstPath' = Join-Path $project '.AL-Go'; 'dstName' = ''; 'srcPath' = '.AL-Go'; 'pattern' = '*.ps1'; 'type' = 'script' }, - @{ 'dstPath' = Join-Path $project '.AL-Go'; 'dstName' = ''; 'srcPath' = '.AL-Go'; 'pattern' = 'settings.json'; 'type' = 'settings' } - ) -} + +$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 = @() + # $removeFiles will hold an array of files, which needs to be removed $removeFiles = @() @@ -166,121 +134,82 @@ if ($projects.Count -gt 1) { } # Loop through all folders in CheckFiles and check if there are any files that needs to be updated -foreach($checkfile in $checkfiles) { - Write-Host "Checking $($checkfile.srcPath)/$($checkfile.pattern)" - $type = $checkfile.type - $srcPath = $checkfile.srcPath - $dstPath = $checkfile.dstPath - $dstFolder = Join-Path $baseFolder $dstPath - $srcFolder = GetSrcFolder -repoType $repoSettings.type -templateUrl $templateUrl -templateFolder $templateFolder -srcPath $srcPath - $originalSrcFolder = $null - if ($originalTemplateFolder -and $type -notlike 'template*settings') { - # Get Original source folder except for template settings - these are applied from the custom template repository - $originalSrcFolder = GetSrcFolder -repoType $repoSettings.type -templateUrl $originalTemplateUrl -templateFolder $originalTemplateFolder -srcPath $srcPath +foreach($fileToInclude in $filesToInclude) { + $type = $fileToInclude.type + $srcPath = $fileToInclude.sourceFullPath + $originalSrcPath = $fileToInclude.originalSourceFullPath + if(-not $originalSrcPath) { + $originalSrcPath = $srcPath } - if ($srcFolder) { - Push-Location -Path $srcFolder - try { - # Remove unused AL-Go system files - $unusedALGoSystemFiles | ForEach-Object { - if (Test-Path -Path (Join-Path $dstFolder $_) -PathType Leaf) { - Write-Host "Remove unused AL-Go system file: $_" - $removeFiles += @(Join-Path $dstPath $_) - } - } - # Loop through all files in the template repository matching the pattern - Get-ChildItem -Path $srcFolder -Filter $checkfile.pattern | ForEach-Object { - # Read the template file and modify it based on the settings - # Compare the modified file with the file in the current repository - if ($checkfile.dstName) { - $filename = $checkfile.dstName - } - else { - $filename = $_.Name - } - Write-Host "- $filename" - $dstFile = Join-Path $dstFolder $filename - $srcFile = $_.FullName - $originalSrcFile = $srcFile - $isFileDirectALGo = $isDirectALGo - Write-Host "SrcFolder: $srcFolder" - if ($originalSrcFolder) { - # if SrcFile is a custom template repository, we need to find the file in the "original" template repository - $fname = Join-Path $originalSrcFolder (Resolve-Path $srcFile -Relative) - if (Test-Path -Path $fname -PathType Leaf) { - Write-Host "File is available in the 'original' template repository" - $originalSrcFile = $fname - $isFileDirectALGo = IsDirectALGo -templateUrl $originalTemplateUrl - } - } - $dstFileExists = Test-Path -Path $dstFile -PathType Leaf - if ($unusedALGoSystemFiles -contains $fileName) { - # File is not used by AL-Go, remove it if it exists - # do not add it to $updateFiles if it does not exist - if ($dstFileExists) { - Write-Host "Removing $type ($(Join-Path $dstPath $filename)) as it is marked as unused." - $removeFiles += @(Join-Path $dstPath $filename) - } - return - } - switch ($type) { - "workflow" { - # For workflow files, we might need to modify the file based on the settings - $srcContent = GetWorkflowContentWithChangesFromSettings -srcFile $originalsrcFile -repoSettings $repoSettings -depth $depth -includeBuildPP $includeBuildPP - } - "settings" { - # For settings files, we need to modify the file based on the settings - $srcContent = GetModifiedSettingsContent -srcSettingsFile $originalSrcFile -dstSettingsFile $dstFile - } - Default { - # For non-workflow files, just read the file content - $srcContent = Get-ContentLF -Path $originalSrcFile - } - } - # Replace static placeholders - $srcContent = $srcContent.Replace('{TEMPLATEURL}', $templateUrl) + $dstPath = $fileToInclude.destinationFullPath - if ($isFileDirectALGo) { - # If we are 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 - ReplaceOwnerRepoAndBranch -srcContent ([ref]$srcContent) -templateOwner $templateOwner -templateBranch $templateBranch - } + $dstFileExists = Test-Path -Path $dstPath -PathType Leaf - if ($type -eq 'workflow' -and $originalSrcFile -ne $srcFile) { - # Apply customizations from custom template repository - Write-Host "Apply customizations from custom template repository, file: $srcFile" - [Yaml]::ApplyTemplateCustomizations([ref] $srcContent, $srcFile) - } + Write-Host "Processing file: $srcPath -> $dstPath (type: $type)" - if ($dstFileExists) { - if ($type -eq 'workflow') { - Write-Host "Apply customizations from current repository, file: $dstFile" - [Yaml]::ApplyFinalCustomizations([ref] $srcContent, $dstFile) - } + 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 + # Replace static placeholders + $srcContent = $srcContent.Replace('{TEMPLATEURL}', $templateUrl) + } + "settings" { + # For settings files, we need to modify the file based on the settings + $srcContent = GetModifiedSettingsContent -srcSettingsFile $originalSrcPath -dstSettingsFile $dstPath + } + Default { + # For non-workflow files, just read the file content + $srcContent = Get-ContentLF -Path $originalSrcPath + } + } - # file exists, compare and add to $updateFiles if different - $dstContent = Get-ContentLF -Path $dstFile - if ($dstContent -cne $srcContent) { - Write-Host "Updated $type ($(Join-Path $dstPath $filename)) available" - $updateFiles += @{ "DstFile" = Join-Path $dstPath $filename; "content" = $srcContent } - } - else { - Write-Host "No changes in $type ($(Join-Path $dstPath $filename))" - } - } - else { - # new file, add to $updateFiles - Write-Host "New $type ($(Join-Path $dstPath $filename)) available" - $updateFiles += @{ "DstFile" = Join-Path $dstPath $filename; "content" = $srcContent } - } - } + if ($isDirectALGo) { + # If we are 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 + ReplaceOwnerRepoAndBranch -srcContent ([ref]$srcContent) -templateOwner $templateOwner -templateBranch $templateBranch + } + + if ($type -eq 'workflow' -and $originalSrcPath -ne $srcPath) { + # Apply customizations from custom template repository + Write-Host "Apply customizations from custom template repository, file: $srcPath" + [Yaml]::ApplyTemplateCustomizations([ref] $srcContent, $srcPath) + } + + # Get the relative path for the dstPath from the base folder. Don't use Resolve-Path as it will fail if the destination file doesn't exist + $relativeDstPath = $dstPath.Substring($baseFolder.Length + 1) + + if ($dstFileExists) { + if ($type -eq 'workflow') { + Write-Host "Apply customizations from current repository, file: $relativeDstPath" + [Yaml]::ApplyFinalCustomizations([ref] $srcContent, $dstPath) + } + + # file exists, compare and add to $updateFiles if different + $dstContent = Get-ContentLF -Path $dstPath + if ($dstContent -cne $srcContent) { + Write-Host "Available updates for file $relativeDstPath" + $updateFiles += @{ "DstFile" = $relativeDstPath; "content" = $srcContent } } - finally { - Pop-Location + else { + Write-Host "No updates for file $relativeDstPath" } } + else { + # new file, add to $updateFiles + Write-Host "New file available: $relativeDstPath" + $updateFiles += @{ "DstFile" = $relativeDstPath; "content" = $srcContent } + } +} + +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 + Write-Host "File marked for removal: $relativePath" + $relativePath } -$removeFiles = @($removeFiles | Select-Object -Unique) +Pop-Location if ($update -ne 'Y') { # $update not set, just issue a warning in the CI/CD workflow that updates are available @@ -317,7 +246,7 @@ else { exit } - # If $directCommit, then changes are made directly to the default branch + # Clone into a new folder, create a new branch (if not direct commit), and set the location to the new folder $serverUrl, $branch = CloneIntoNewFolder -actor $actor -token $repoWriteToken -updateBranch $updateBranch -DirectCommit $directCommit -newBranchPrefix 'update-al-go-system-files' invoke-git status @@ -328,7 +257,7 @@ else { $updateFiles | ForEach-Object { # Create the destination folder if it doesn't exist $path = [System.IO.Path]::GetDirectoryName($_.DstFile) - if (-not (Test-Path -path $path -PathType Container)) { + if ($path -and -not (Test-Path -path $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)) { diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index bdf268968..e20ecf987 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -10,6 +10,14 @@ Old versions of AL-Go for GitHub uses old and unsupported versions of GitHub act When handling support requests, we will request that you to use the latest version of AL-Go for GitHub and in general, fixes will only be made available in a preview version of AL-Go for GitHub and subsequently in the next version released. +## Changes in effect after October 1st 2026 + + + +### Setting `unusedALGoSystemFiles` will no longer be supported + +The recommended approach is to use the [`customALGoFiles.filesToExclude`](https://aka.ms/algosettings#customALGoFiles) setting to specify files from the AL-Go template that should be excluded from your repositories. + ## Changes in effect after October 1st 2025 diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 29735d63d..de44f3d8d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,3 +1,7 @@ +### Custom AL-Go files + +AL-Go for GitHub now supports updating files from your custom templates via the new `customALGoFiles` setting. Read more at [customALGoFiles](https://aka.ms/algosettings#customALGoFiles). + ### Set default values for workflow inputs A new setting `workflowDefaultInputs` allows you to configure default values for workflow_dispatch inputs. This makes it easier to run workflows manually with consistent settings across your team. @@ -52,6 +56,10 @@ Read more at [workflowDefaultInputs](https://aka.ms/algosettings#workflowDefault - Issue 1960 Deploy Reference Documentation fails - Discussion 1952 Set default values on workflow_dispatch input +### Deprecations + +- `unusedALGoSystemFiles` will be removed after October 1st 2026. Please use [`customALGoFiles.filesToExclude`](https://aka.ms/algosettings#customALGoFiles) instead. + ## v8.0 ### Mechanism to overwrite complex settings type diff --git a/Scenarios/CustomizingALGoForGitHub.md b/Scenarios/CustomizingALGoForGitHub.md index beb2029ed..646dcfab8 100644 --- a/Scenarios/CustomizingALGoForGitHub.md +++ b/Scenarios/CustomizingALGoForGitHub.md @@ -201,6 +201,129 @@ Repositories based on your custom template will notify you that changes are avai > [!TIP] > You can setup the Update AL-Go System Files workflow to run on a schedule to uptake new releases of AL-Go for GitHub regularly. +## Using custom template files + +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`. + +`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`. +- `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`. + +> [!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. + +`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`. +- `filter`: A string to use for filtering in the specified source path. It can contain `*` and `?` wildcards. _Example_: `notRelevantScript.ps1` or `*-internal.ps1` + +> [!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. + +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 | +| No | Yes/No | Yes | The file is **_not_ created** in the end repo, as it's matched for exclusion | + +### Examples of using custom template files + +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 + +```json +"customALGoFiles": { + "filesToInclude": [ + { + "sourceFolder": ".github/customScripts", + "filter": "*.ps1", + "destinationFolder": ".AL-Go/scripts", + "perProject": true + } + ] +} +``` + +This configuration will copy all PowerShell scripts from `.github/customScripts` in your template repository to the `.AL-Go/scripts` folder in each project of your target repository. + +#### Example 2: Excluding a specific script from updates + +```json +"customALGoFiles": { + "filesToInclude": [ + { + "sourceFolder": ".github/customScripts", + "filter": "*.ps1", + "destinationFolder": ".AL-Go/scripts", + "perProject": true + } + ], + "filesToExclude": [ + { + "sourceFolder": ".github/customScripts", + "filter": "DoNotPropagate.ps1" + } + ] +} +``` + +This will update all `.ps1` scripts except `DoNotPropagate.ps1`, which will be excluded from the update process. + +#### Example 3: Updating workflow files and excluding one + +```json +"customALGoFiles": { + "filesToInclude": [], + "filesToExclude": [ + { + "sourceFolder": ".github/workflows", + "filter": "experimental-workflow.yaml" + } + ] +} +``` + +All workflow YAML files will be updated except `experimental-workflow.yaml`, which will be removed from the target repository if present. +Note that AL-Go for GitHub already syncs all workflow files under `.github/workflows` by default, so you don't need to specify `filesToInclude`; however, any files matched by `filesToExclude` will be excluded from this default sync. + +#### Example 4: Multiple update and exclude rules + +```json +"customALGoFiles": { + "filesToInclude": [ + { + "sourceFolder": "shared/config", + "filter": "*.json", + "destinationFolder": "config" + }, + { + "sourceFolder": ".github/scripts", + "filter": "*.ps1", + "destinationFolder": ".github/scripts" + } + ], + "filesToExclude": [ + { + "sourceFolder": "shared/config", + "filter": "legacy-config.json" + } + ] +} +``` + +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. + +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 Using a fork of AL-Go for GitHub to have your "own" public version of AL-Go for GitHub gives you the maximum customization capabilities. It does however also come with the most work. diff --git a/Scenarios/settings.md b/Scenarios/settings.md index 5fe3bba5b..193e54878 100644 --- a/Scenarios/settings.md +++ b/Scenarios/settings.md @@ -119,7 +119,7 @@ The repository settings are only read from the repository settings file (.github | keyVaultCodesignCertificateName | When developing AppSource Apps, your app needs to be code signed.
Name of a certificate stored in your KeyVault that can be used to codesigning. To use this setting you will also need enable Azure KeyVault in your AL-Go project. Read [this](UseAzureKeyVault.md) for more information | | | applicationInsightsConnectionStringSecretName | This setting specifies the name (**NOT the secret**) of a secret containing the application insights connection string for the apps. | applicationInsightsConnectionString | | storageContextSecretName | This setting specifies the name (**NOT the secret**) of a secret containing a json string with StorageAccountName, ContainerName, BlobName and StorageAccountKey or SAS Token. If this secret exists, AL-Go will upload builds to this storage account for every successful build.
The BcContainerHelper function New-ALGoStorageContext can create a .json structure with this content. | StorageContext | -| alwaysBuildAllProjects | This setting only makes sense if the repository is setup for multiple projects.
Standard behavior of the CI/CD workflow is to only build the projects, in which files have changes when running the workflow due to a push or a pull request | false | +| alwaysBuildAllProjects (**deprecated**) | This setting only makes sense if the repository is setup for multiple projects.
Standard behavior of the CI/CD workflow is to only build the projects, in which files have changes when running the workflow due to a push or a pull request | false | | fullBuildPatterns | Use this setting to list important files and folders. Changes to any of these files and folders would trigger a full Pull Request build (all AL-Go projects will be built).
*Examples*:
1. Specifying `fullBuildPatterns` as `[ "Build/*" ]` means that any changes from a PR to the `Build` folder would trigger a full build.
2. Specifying `fullBuildPatterns` as `[ "*" ]` means that any changes from a PR would trigger a full build and it is equivalent to setting `alwaysBuildAllProjects` to `true`. | [ ] | | skipUpgrade | This setting is used to signal to the pipeline to NOT run upgrade and ignore previous releases of the app. | false | | cacheImageName | When using self-hosted runners, cacheImageName specifies the prefix for the docker image created for increased performance | my | @@ -239,8 +239,9 @@ Please read the release notes carefully when installing new versions of AL-Go fo | doNotRunBcptTests | This setting forces the pipeline to NOT run the performance tests in testFolders. Performance tests are still being built and published. Note this setting can be set in a [workflow specific settings file](#where-are-the-settings-located) to only apply to that workflow | false | | memoryLimit | Specifies the memory limit for the build container. By default, this is left to BcContainerHelper to handle and will currently be set to 8G | 8G | | 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 | 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. | [ ] | +| 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": [] }` ## Overwrite settings diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index 8455e9330..ba31d127c 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -1,7 +1,7 @@ -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') -Force +Import-Module (Join-Path $PSScriptRoot '../Actions/.Modules/ReadSettings.psm1') $errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0 Describe "CheckForUpdates Action Tests" { @@ -33,7 +33,7 @@ Describe "CheckForUpdates Action Tests" { } } -Describe('YamlClass Tests') { +Describe "YamlClass Tests" { BeforeAll { $actionName = "CheckForUpdates" [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'scriptRoot', Justification = 'False positive.')] @@ -81,11 +81,11 @@ Describe('YamlClass Tests') { $count | Should -be 19 # Replace all occurances of 'shell: powershell' with 'shell: pwsh' - $yaml.ReplaceAll('shell: powershell','shell: pwsh') + $yaml.ReplaceAll('shell: powershell', 'shell: pwsh') $yaml.content[46].Trim() | Should -be 'shell: pwsh' # Replace Permissions - $yaml.Replace('Permissions:/',@('contents: write','actions: read')) + $yaml.Replace('Permissions:/', @('contents: write', 'actions: read')) $yaml.content[44].Trim() | Should -be 'shell: pwsh' $yaml.content.Count | Should -be 72 @@ -212,9 +212,9 @@ Describe "CheckForUpdates Action: CheckForUpdates.HelperFunctions.ps1" { $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve Import-Module (Join-Path $scriptRoot "..\Github-Helper.psm1") -DisableNameChecking -Force . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'actionScript', Justification = 'False positive.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'tmpSrcFile', Justification = 'False positive.')] $tmpSrcFile = Join-Path $PSScriptRoot "tempSrcFile.json" - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'actionScript', Justification = 'False positive.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'tmpDstFile', Justification = 'False positive.')] $tmpDstFile = Join-Path $PSScriptRoot "tempDestFile.json" } @@ -702,8 +702,7 @@ Describe "CheckForUpdates Action: CheckForUpdates.HelperFunctions.ps1" { } # Apply the defaults - should throw validation error - { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | - Should -Throw "*not a valid choice*" + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | Should -Throw "*not a valid choice*" } It 'ApplyWorkflowDefaultInputs handles inputs without existing default' { @@ -811,8 +810,7 @@ Describe "CheckForUpdates Action: CheckForUpdates.HelperFunctions.ps1" { } # Apply the defaults - should throw validation error - { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | - Should -Throw "*Expected boolean value*" + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | Should -Throw "*Expected boolean value*" } It 'ApplyWorkflowDefaultInputs validates number type mismatch' { @@ -842,8 +840,7 @@ Describe "CheckForUpdates Action: CheckForUpdates.HelperFunctions.ps1" { } # Apply the defaults - should throw validation error - { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | - Should -Throw "*Expected number value*" + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | Should -Throw "*Expected number value*" } It 'ApplyWorkflowDefaultInputs validates string type mismatch' { @@ -873,8 +870,7 @@ Describe "CheckForUpdates Action: CheckForUpdates.HelperFunctions.ps1" { } # Apply the defaults - should throw validation error - { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | - Should -Throw "*Expected string value*" + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | Should -Throw "*Expected string value*" } It 'ApplyWorkflowDefaultInputs validates choice type' { @@ -942,8 +938,7 @@ Describe "CheckForUpdates Action: CheckForUpdates.HelperFunctions.ps1" { } # Apply the defaults - should throw validation error - { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | - Should -Throw "*not a valid choice*" + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | Should -Throw "*not a valid choice*" } It 'ApplyWorkflowDefaultInputs validates choice value with case-sensitive matching' { @@ -987,8 +982,7 @@ Describe "CheckForUpdates Action: CheckForUpdates.HelperFunctions.ps1" { ) } - { ApplyWorkflowDefaultInputs -yaml $yaml2 -repoSettings $repoSettings2 -workflowName "Test Workflow" } | - Should -Throw "*case-sensitive match required*" + { ApplyWorkflowDefaultInputs -yaml $yaml2 -repoSettings $repoSettings2 -workflowName "Test Workflow" } | Should -Throw "*case-sensitive match required*" # Test 3: Uppercase version should also fail $yaml3 = [Yaml]::new($yamlContent) @@ -998,8 +992,7 @@ Describe "CheckForUpdates Action: CheckForUpdates.HelperFunctions.ps1" { ) } - { ApplyWorkflowDefaultInputs -yaml $yaml3 -repoSettings $repoSettings3 -workflowName "Test Workflow" } | - Should -Throw "*case-sensitive match required*" + { ApplyWorkflowDefaultInputs -yaml $yaml3 -repoSettings $repoSettings3 -workflowName "Test Workflow" } | Should -Throw "*case-sensitive match required*" } It 'ApplyWorkflowDefaultInputs handles inputs without type specification' { @@ -1107,3 +1100,1361 @@ Describe "CheckForUpdates Action: CheckForUpdates.HelperFunctions.ps1" { $yaml.Get('on:/workflow_dispatch:/inputs:/input2:/default:').content -join '' | Should -Be 'default: false' } } + +Describe "ResolveFilePaths" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + + $rootFolder = $PSScriptRoot + + $sourceFolder = Join-Path $rootFolder "sourceFolder" + if (-not (Test-Path $sourceFolder)) { + New-Item -Path $sourceFolder -ItemType Directory | Out-Null + } + # Create a source folder structure + New-Item -Path (Join-Path $sourceFolder "folder/File1.txt") -ItemType File -Force | Out-Null + New-Item -Path (Join-Path $sourceFolder "folder/File2.log") -ItemType File -Force | Out-Null + New-Item -Path (Join-Path $sourceFolder "folder/File3.txt") -ItemType File -Force | Out-Null + New-Item -Path (Join-Path $sourceFolder "folder/File4.md") -ItemType File -Force | Out-Null + + $originalSourceFolder = Join-Path $rootFolder "originalSourceFolder" + if (-not (Test-Path $originalSourceFolder)) { + New-Item -Path $originalSourceFolder -ItemType Directory | Out-Null + } + New-Item -Path (Join-Path $originalSourceFolder "folder/File1.txt") -ItemType File -Force | Out-Null + New-Item -Path (Join-Path $originalSourceFolder "folder/File2.log") -ItemType File -Force | Out-Null + + # File tree: + # sourceFolder + # └── folder + # ├── File1.txt + # ├── File2.log + # ├── File3.txt + # └── File4.md + } + + AfterAll { + # Clean up + if (Test-Path $sourceFolder) { + Remove-Item -Path $sourceFolder -Recurse -Force + } + + if (Test-Path $originalSourceFolder) { + Remove-Item -Path $originalSourceFolder -Recurse -Force + } + } + + It 'ResolveFilePaths with specific files extensions' { + $destinationFolder = "destinationFolder" + $destinationFolder = Join-Path $rootFolder $destinationFolder + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "*.txt"; "destinationFolder" = 'newFolder'; "destinationName" = '' } + @{ "sourceFolder" = "folder"; "filter" = "*.md"; "destinationFolder" = 'newFolder'; "destinationName" = '' } + ) + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 3 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File1.txt") + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "newFolder/File1.txt") + $fullFilePaths[0].type | Should -Be '' + $fullFilePaths[1].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File3.txt") + $fullFilePaths[1].destinationFullPath | Should -Be (Join-Path $destinationFolder "newFolder/File3.txt") + $fullFilePaths[1].type | Should -Be '' + $fullFilePaths[2].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File4.md") + $fullFilePaths[2].destinationFullPath | Should -Be (Join-Path $destinationFolder "newFolder/File4.md") + $fullFilePaths[2].type | Should -Be '' + } + + It 'ResolveFilePaths with specific destination names' { + $destinationFolder = "destinationFolder" + $destinationFolder = Join-Path $rootFolder $destinationFolder + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "destinationFolder" = 'newFolder'; "destinationName" = "CustomFile1.txt" } + @{ "sourceFolder" = "folder"; "filter" = "File2.log"; "destinationFolder" = 'newFolder'; "destinationName" = "CustomFile2.log" } + ) + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 2 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File1.txt") + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "newFolder/CustomFile1.txt") + $fullFilePaths[0].type | Should -Be '' + $fullFilePaths[1].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File2.log") + $fullFilePaths[1].destinationFullPath | Should -Be (Join-Path $destinationFolder "newFolder/CustomFile2.log") + $fullFilePaths[1].type | Should -Be '' + } + + It 'ResolveFilePaths with type' { + $destinationFolder = "destinationFolder" + $destinationFolder = Join-Path $PSScriptRoot $destinationFolder + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "*.txt"; "destinationFolder" = "folder"; "destinationName" = ''; type = "text" } + @{ "sourceFolder" = "folder"; "filter" = "*.md"; "destinationFolder" = "folder"; "destinationName" = ''; type = "markdown" } + ) + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder + + # Verify destinationFullPath is not filled + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 3 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File1.txt") + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") + $fullFilePaths[0].type | Should -Be "text" + $fullFilePaths[1].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File3.txt") + $fullFilePaths[1].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File3.txt") + $fullFilePaths[1].type | Should -Be "text" + $fullFilePaths[2].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File4.md") + $fullFilePaths[2].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File4.md") + $fullFilePaths[2].type | Should -Be "markdown" + } + + It 'ResolveFilePaths with original source folder' { + $destinationFolder = "destinationFolder" + $destinationFolder = Join-Path $PSScriptRoot $destinationFolder + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "*.txt"; "destinationFolder" = "newFolder"; "destinationName" = ''; type = "text" } + @{ "sourceFolder" = "folder"; "filter" = "*.md"; "destinationFolder" = "newFolder"; "destinationName" = ''; type = "markdown" } + ) + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -originalSourceFolder $originalSourceFolder + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 3 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File1.txt") + $fullFilePaths[0].originalSourceFullPath | Should -Be (Join-Path $originalSourceFolder "folder/File1.txt") + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "newFolder/File1.txt") + $fullFilePaths[0].type | Should -Be "text" + + $fullFilePaths[1].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File3.txt") # File3.txt doesn't exist in original source folder, so it should still point to the source folder + $fullFilePaths[1].originalSourceFullPath | Should -Be $null + $fullFilePaths[1].destinationFullPath | Should -Be (Join-Path $destinationFolder "newFolder/File3.txt") + $fullFilePaths[1].type | Should -Be "text" + + $fullFilePaths[2].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File4.md") # File4.md doesn't exist in original source folder, so it should still point to the source folder + $fullFilePaths[2].originalSourceFullPath | Should -Be $null + $fullFilePaths[2].destinationFullPath | Should -Be (Join-Path $destinationFolder "newFolder/File4.md") + $fullFilePaths[2].type | Should -Be "markdown" + } + + It 'ResolveFilePaths populates the originalSourceFullPath property only if the origin is not a custom template' { + $destinationFolder = "destinationFolder" + $destinationFolder = Join-Path $PSScriptRoot $destinationFolder + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "*.txt"; "destinationFolder" = "newFolder"; "destinationName" = ''; "origin" = "custom template"; } + @{ "sourceFolder" = "folder"; "filter" = "*.log"; "destinationFolder" = "newFolder"; "destinationName" = ''; } + ) + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -originalSourceFolder $originalSourceFolder + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 3 + + # First file has type containing "template", so originalSourceFullPath should be $null + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File1.txt") + $fullFilePaths[0].originalSourceFullPath | Should -Be $null + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "newFolder/File1.txt") + + # Second file has is not present in original source folder, so originalSourceFullPath should be $null + $fullFilePaths[1].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File3.txt") + $fullFilePaths[1].originalSourceFullPath | Should -Be $null + $fullFilePaths[1].destinationFullPath | Should -Be (Join-Path $destinationFolder "newFolder/File3.txt") + + # Third file has type not containing "template", so originalSourceFullPath should be populated + $fullFilePaths[2].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File2.log") + $fullFilePaths[2].originalSourceFullPath | Should -Be (Join-Path $originalSourceFolder "folder/File2.log") + $fullFilePaths[2].destinationFullPath | Should -Be (Join-Path $destinationFolder "newFolder/File2.log") + } + + It 'ResolveFilePaths with a single project' { + $destinationFolder = "destinationFolder" + $destinationFolder = Join-Path $PSScriptRoot $destinationFolder + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "*.txt"; type = "text"; perProject = $true } + @{ "sourceFolder" = "folder"; "filter" = "*.md"; type = "markdown"; } + ) + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @("SomeProject") + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 3 + + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File1.txt") + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "SomeProject/folder/File1.txt") + $fullFilePaths[0].type | Should -Be "text" + + $fullFilePaths[1].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File3.txt") + $fullFilePaths[1].destinationFullPath | Should -Be (Join-Path $destinationFolder "SomeProject/folder/File3.txt") + $fullFilePaths[1].type | Should -Be "text" + + $fullFilePaths[2].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File4.md") + $fullFilePaths[2].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File4.md") + $fullFilePaths[2].type | Should -Be "markdown" + } + + It 'ResolveFilePaths with multiple projects' { + $destinationFolder = "destinationFolder" + $destinationFolder = Join-Path $PSScriptRoot $destinationFolder + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "*.txt"; type = "text"; perProject = $true } + @{ "sourceFolder" = "folder"; "filter" = "*.md"; type = "markdown"; } + ) + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @("ProjectA", "ProjectB") + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 5 + + # ProjectA files: File1.txt + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File1.txt") + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectA/folder/File1.txt") + $fullFilePaths[0].type | Should -Be "text" + + # ProjectB files: File1.txt + $fullFilePaths[1].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File1.txt") + $fullFilePaths[1].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectB/folder/File1.txt") + $fullFilePaths[1].type | Should -Be "text" + + # ProjectA files: File3.txt + $fullFilePaths[2].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File3.txt") + $fullFilePaths[2].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectA/folder/File3.txt") + $fullFilePaths[2].type | Should -Be "text" + + # ProjectB files: File3.txt + $fullFilePaths[3].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File3.txt") + $fullFilePaths[3].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectB/folder/File3.txt") + $fullFilePaths[3].type | Should -Be "text" + + # Non-per-project file + $fullFilePaths[4].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File4.md") + $fullFilePaths[4].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File4.md") + $fullFilePaths[4].type | Should -Be "markdown" + } + + 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" } + ) + + # Intentionally call ResolveFilePaths with the real sourceFolder (so external file should not be included) + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder + + # Ensure none of the returned sourceFullPath entries point to the external file + $fullFilePaths | ForEach-Object { $_.sourceFullPath | Should -Not -Be $externalFile } + + # 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 + + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "*.doesnotexist"; "destinationFolder" = "newFolder" } + ) + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder + + # No matching files should be returned + $fullFilePaths | Should -BeNullOrEmpty + } + + It 'ResolveFilePaths with perProject true and empty projects returns no per-project entries' { + $destinationFolder = "destinationFolder" + $destinationFolder = Join-Path $PSScriptRoot $destinationFolder + + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "*.txt"; type = "text"; perProject = $true } + ) + + # Intentionally pass an empty projects array + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @() + + # Behavior: when projects is empty, no per-project entries should be created + $fullFilePaths | Should -BeNullOrEmpty + } + + It 'ResolveFilePaths returns empty array when files parameter is null or empty' { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + + # Explicitly pass $null and @() to verify both code paths behave the same + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $null -destinationFolder $destinationFolder + $fullFilePaths | Should -BeNullOrEmpty + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files @() -destinationFolder $destinationFolder + $fullFilePaths | Should -BeNullOrEmpty + } + + It 'ResolveFilePaths defaults destination folder when none is provided' { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt" } + ) + + $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 avoids duplicate destination entries' { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt" } + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "destinationFolder" = "folder" } + ) + + $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 treats dot project as repository root for per-project files' { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; perProject = $true; "destinationFolder" = "custom" } + ) + + $projects = @('.', 'ProjectAlpha') + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects $projects + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 2 + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "custom/File1.txt") + $fullFilePaths[1].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectAlpha/custom/File1.txt") + } + + It 'ResolveFilePaths handles sourceFolder with trailing slashes' { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder/"; "filter" = "File1.txt" } + @{ "sourceFolder" = "folder\"; "filter" = "File2.log" } + ) + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 2 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File1.txt") + $fullFilePaths[1].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File2.log") + } + + It 'ResolveFilePaths handles deeply nested folder structures' { + # Create a deeply nested folder structure + $deepFolder = Join-Path $sourceFolder "level1/level2/level3" + New-Item -Path $deepFolder -ItemType Directory -Force | Out-Null + $deepFile = Join-Path $deepFolder "deep.txt" + Set-Content -Path $deepFile -Value "deep file" + + try { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = "level1/level2/level3"; "filter" = "deep.txt"; "destinationFolder" = "output" } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be $deepFile + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "output/deep.txt") + } + finally { + if (Test-Path $deepFile) { Remove-Item -Path $deepFile -Force } + if (Test-Path (Join-Path $sourceFolder "level1")) { Remove-Item -Path (Join-Path $sourceFolder "level1") -Recurse -Force } + } + } + + It 'ResolveFilePaths handles mixed perProject true and false in same call' { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; perProject = $true } + @{ "sourceFolder" = "folder"; "filter" = "File2.log"; perProject = $false } + @{ "sourceFolder" = "folder"; "filter" = "File3.txt"; perProject = $true } + ) + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @("ProjectA") + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 3 + + # File1.txt is per-project + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectA/folder/File1.txt") + + # File2.log is not per-project + $fullFilePaths[1].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File2.log") + + # File3.txt is per-project + $fullFilePaths[2].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectA/folder/File3.txt") + } + + It 'ResolveFilePaths handles files with no extension' { + $noExtFile = Join-Path $sourceFolder "folder/README" + Set-Content -Path $noExtFile -Value "readme content" + + try { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "README" } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be $noExtFile + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/README") + } + finally { + if (Test-Path $noExtFile) { Remove-Item -Path $noExtFile -Force } + } + } + + It 'ResolveFilePaths handles special characters in filenames' { + $specialFile = Join-Path $sourceFolder "folder/File-With-Dashes_And_Underscores.txt" + Set-Content -Path $specialFile -Value "special chars" + + try { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File-With-Dashes_And_Underscores.txt" } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be $specialFile + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File-With-Dashes_And_Underscores.txt") + } + finally { + if (Test-Path $specialFile) { Remove-Item -Path $specialFile -Force } + } + } + + It 'ResolveFilePaths handles wildcard filters correctly' { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File*.txt" } + ) + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 2 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File1.txt") + $fullFilePaths[1].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File3.txt") + } + + It 'ResolveFilePaths with perProject skips duplicate files across projects' { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; perProject = $true } + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; perProject = $true; "destinationFolder" = "folder" } + ) + + $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 handles empty sourceFolder value' { + # Create a file in the root of the sourceFolder + $rootFile = Join-Path $sourceFolder "RootFile.txt" + Set-Content -Path $rootFile -Value "root file content" + + try { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = ""; "filter" = "RootFile.txt" } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -Not -BeNullOrEmpty + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be $rootFile + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "RootFile.txt") + } + finally { + if (Test-Path $rootFile) { Remove-Item -Path $rootFile -Force } + } + } + + It 'ResolveFilePaths handles multiple filters matching same file' { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "*.txt" } + @{ "sourceFolder" = "folder"; "filter" = "File1.*" } + ) + + $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 + } + + It 'ResolveFilePaths correctly resolves originalSourceFullPath only when file exists in original folder' { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + + # Create an additional file only in the source folder (not in original) + $onlyInSourceFile = Join-Path $sourceFolder "folder/OnlyInSource.txt" + Set-Content -Path $onlyInSourceFile -Value "only in source" + + try { + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "*.txt" } + @{ "sourceFolder" = "folder"; "filter" = "*.log" } + ) + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -originalSourceFolder $originalSourceFolder + + # Files that exist in original source should have originalSourceFullPath set + $file1 = $fullFilePaths | Where-Object { $_.sourceFullPath -eq (Join-Path $sourceFolder "folder/File1.txt") } + $file1.originalSourceFullPath | Should -Be (Join-Path $originalSourceFolder "folder/File1.txt") + + $file2 = $fullFilePaths | Where-Object { $_.sourceFullPath -eq (Join-Path $sourceFolder "folder/File2.log") } + $file2.originalSourceFullPath | Should -Be (Join-Path $originalSourceFolder "folder/File2.log") + + # Files that don't exist in original source should have originalSourceFullPath as $null + $fileOnlyInSource = $fullFilePaths | Where-Object { $_.sourceFullPath -eq $onlyInSourceFile } + $fileOnlyInSource | Should -Not -BeNullOrEmpty + $fileOnlyInSource.originalSourceFullPath | Should -Be $null + } + finally { + if (Test-Path $onlyInSourceFile) { Remove-Item -Path $onlyInSourceFile -Force } + } + } + + It 'ResolveFilePaths with origin custom template and no originalSourceFolder skips files' { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "origin" = "custom template" } + @{ "sourceFolder" = "folder"; "filter" = "File2.log" } + ) + + # Don't pass originalSourceFolder - custom template files should be skipped + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -Not -BeNullOrEmpty + # Only File2.log should be included + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File2.log") + } + + It 'ResolveFilePaths handles case-insensitive filter matching on Windows' { + # Create files with different case + $upperFile = Join-Path $sourceFolder "folder/UPPER.TXT" + $lowerFile = Join-Path $sourceFolder "folder/lower.txt" + Set-Content -Path $upperFile -Value "upper" + Set-Content -Path $lowerFile -Value "lower" + + try { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "*.txt" } + ) + + $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 | Should -Not -BeNullOrEmpty + $lowerMatch | Should -Not -BeNullOrEmpty + } + finally { + if (Test-Path $upperFile) { Remove-Item -Path $upperFile -Force } + if (Test-Path $lowerFile) { Remove-Item -Path $lowerFile -Force } + } + } +} + +Describe "ReplaceOwnerRepoAndBranch" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + } + + It "Replaces owner, repo, and branch in workflow content" { + $srcContent = [ref]@" +jobs: + build: + uses: microsoft/AL-Go-Actions@main +"@ + $templateOwner = "contoso" + $templateBranch = "dev" + ReplaceOwnerRepoAndBranch -srcContent $srcContent -templateOwner $templateOwner -templateBranch $templateBranch + $srcContent.Value | Should -Be @" +jobs: + build: + uses: contoso/AL-Go/Actions@dev +"@ + } +} + +Describe "IsDirectALGo" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + } + It "Returns true for direct AL-Go repo URL" { + IsDirectALGo -templateUrl "https://github.com/contoso/AL-Go@main" | Should -Be True + } + It "Returns false for non-direct AL-Go repo URL" { + IsDirectALGo -templateUrl "https://github.com/contoso/OtherRepo@main" | Should -Be False + } +} + +Describe "GetFilesToUpdate (general files to update logic)" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + + # Create template folder with test files + $templateFolder = Join-Path $PSScriptRoot "template" + New-Item -ItemType Directory -Path $templateFolder -Force | Out-Null + + New-Item -ItemType Directory -Path (Join-Path $templateFolder "subfolder") -Force | Out-Null + + $testPSFile = Join-Path $templateFolder "test.ps1" + Set-Content -Path $testPSFile -Value "# test ps file" + + $testTxtFile = Join-Path $templateFolder "test.txt" + Set-Content -Path $testTxtFile -Value "test txt file" + + $testTxtFile2 = Join-Path $templateFolder "test2.txt" + Set-Content -Path $testTxtFile2 -Value "test txt file 2" + + $testSubfolderFile = Join-Path $templateFolder "subfolder/testsub.txt" + Set-Content -Path $testSubfolderFile -Value "test subfolder txt file" + + $testSubfolderFile2 = Join-Path $templateFolder "subfolder/testsub2.txt" + Set-Content -Path $testSubfolderFile2 -Value "test subfolder txt file 2" + + # Display the created files structure for template folder + # . + # ├── test.ps1 + # ├── test.txt + # └── test2.txt + # └── subfolder + # └── testsub.txt + } + + AfterAll { + if (Test-Path $templateFolder) { + Remove-Item -Path $templateFolder -Recurse -Force + } + } + + It "Returns the correct files to update with filters" { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.ps1" }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = 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') + + # No files to remove + $filesToExclude | Should -BeNullOrEmpty + + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt" }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = 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[1].sourceFullPath | Should -Be $testTxtFile2 + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + + # No files to remove + $filesToExclude | Should -BeNullOrEmpty + } + + It 'Returns the correct files with destinationFolder' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt"; destinationFolder = "customFolder" }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = 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[1].sourceFullPath | Should -Be $testTxtFile2 + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'customFolder/test2.txt') + + # No files to remove + $filesToExclude | Should -BeNullOrEmpty + + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt"; destinationFolder = "customFolder" }) + filesToExclude = @(@{ filter = "test2.txt" }) + } + } + + $filesToInclude, $filesToExclude = 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') + + # 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') + } + + It 'Returns the correct files with destinationName' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.ps1"; destinationName = "renamed.txt" }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = 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') + + # No files to remove + $filesToExclude | Should -BeNullOrEmpty + + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.ps1"; destinationFolder = 'dstPath'; destinationName = "renamed.txt" }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = 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') + } + + It 'Return the correct files with types' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.ps1"; type = "script" }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = 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].type | Should -Be "script" + + # No files to remove + $filesToExclude | Should -BeNullOrEmpty + + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt"; type = "text" }) + filesToExclude = @(@{ filter = "test.txt" }) + } + } + + $filesToInclude, $filesToExclude = 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].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') + } + + It 'Return the correct files when unusedALGoSystemFiles is specified' { + $settings = @{ + type = "nonPTE" + unusedALGoSystemFiles = @("test.ps1") + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*" }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = 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[1].sourceFullPath | Should -Be $testTxtFile2 + $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') + } + + It 'GetFilesToUpdate with perProject true and empty projects returns no per-project entries' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt"; type = "text"; perProject = $true }) + filesToExclude = @() + } + } + + # Pass empty projects array + $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 + } + + It 'GetFilesToUpdate ignores filesToExclude patterns that do not match any file' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt" }) + filesToExclude = @(@{ filter = "no-match-*.none" }) + } + } + + $filesToInclude, $filesToExclude = 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[1].sourceFullPath | Should -Be $testTxtFile2 + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + + $filesToExclude | Should -BeNullOrEmpty + } + + It 'GetFilesToUpdate duplicates per-project includes for each project including the repository root' { + $perProjectFile = Join-Path $templateFolder "perProjectFile.algo" + Set-Content -Path $perProjectFile -Value "per project" + + try { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "perProjectFile.algo"; perProject = $true; destinationFolder = 'custom' }) + filesToExclude = @() + } + } + + $projects = @('.', 'ProjectOne') + $filesToInclude, $filesToExclude = 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' + + $filesToInclude.destinationFullPath | Should -Contain $rootDestination + $filesToInclude.destinationFullPath | Should -Contain $projectDestination + + $filesToExclude | Should -BeNullOrEmpty + } + finally { + if (Test-Path $perProjectFile) { + Remove-Item -Path $perProjectFile -Force + } + } + } + + It 'GetFilesToUpdate adds custom template settings only when original template folder is provided' { + $customTemplateFolder = Join-Path $PSScriptRoot "customTemplateFolder" + $originalTemplateFolder = Join-Path $PSScriptRoot "originalTemplateFolder" + + New-Item -ItemType Directory -Path $customTemplateFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $customTemplateFolder '.github') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $customTemplateFolder '.AL-Go') -Force | Out-Null + Set-Content -Path (Join-Path $customTemplateFolder (Join-Path '.github' $RepoSettingsFileName)) -Value '{}' -Encoding UTF8 + Set-Content -Path (Join-Path $customTemplateFolder (Join-Path '.AL-Go' $ALGoSettingsFileName)) -Value '{}' -Encoding UTF8 + + New-Item -ItemType Directory -Path $originalTemplateFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $originalTemplateFolder '.github') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $originalTemplateFolder '.AL-Go') -Force | Out-Null + Set-Content -Path (Join-Path $originalTemplateFolder (Join-Path '.github' $RepoSettingsFileName)) -Value '{"original":true}' -Encoding UTF8 + Set-Content -Path (Join-Path $originalTemplateFolder (Join-Path '.AL-Go' $ALGoSettingsFileName)) -Value '{"original":true}' -Encoding UTF8 + + try { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = '' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + } + } + + $baseFolder = 'baseFolder' + $projects = @('ProjectA') + + $filesWithoutOriginal, $excludesWithoutOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -projects $projects + + $filesWithoutOriginal | Should -Not -BeNullOrEmpty + + $repoSettingsDestination = Join-Path $baseFolder (Join-Path '.github' $RepoSettingsFileName) + $projectSettingsRelative = Join-Path 'ProjectA' '.AL-Go' + $projectSettingsRelative = Join-Path $projectSettingsRelative $ALGoSettingsFileName + $projectSettingsDestination = Join-Path $baseFolder $projectSettingsRelative + + $filesWithoutOriginal.destinationFullPath | Should -Contain $repoSettingsDestination + $filesWithoutOriginal.destinationFullPath | Should -Contain $projectSettingsDestination + $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 | Should -Not -BeNullOrEmpty + + $filesWithOriginal.destinationFullPath | Should -Contain $repoSettingsDestination + $filesWithOriginal.destinationFullPath | Should -Contain $projectSettingsDestination + $filesWithOriginal.destinationFullPath | Should -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateRepoSettingsFileName)) + $filesWithOriginal.destinationFullPath | Should -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateProjectSettingsFileName)) + + $excludesWithoutOriginal | Should -BeNullOrEmpty + $excludesWithOriginal | Should -BeNullOrEmpty + } + finally { + if (Test-Path $customTemplateFolder) { + Remove-Item -Path $customTemplateFolder -Recurse -Force + } + if (Test-Path $originalTemplateFolder) { + Remove-Item -Path $originalTemplateFolder -Recurse -Force + } + } + } + + It 'GetFilesToUpdate excludes files that match both include and exclude patterns' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt" }) + filesToExclude = @(@{ filter = "test.txt" }) + } + } + + $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") } + $includedTestTxt | Should -BeNullOrEmpty + + # test.txt should be in filesToExclude + $excludedTestTxt = $filesToExclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") } + $excludedTestTxt | Should -Not -BeNullOrEmpty + } + + It 'GetFilesToUpdate ignores exclude patterns that do not match any included file' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt" }) + filesToExclude = @(@{ filter = "nonexistent.xyz" }) + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + + # All txt files should be included + $filesToInclude | Should -Not -BeNullOrEmpty + $txtFiles = $filesToInclude | Where-Object { $_.sourceFullPath -like "*.txt" } + $txtFiles.Count | Should -BeGreaterThan 0 + + # Exclude list should not contain the non-matching pattern + $excludedNonExistent = $filesToExclude | Where-Object { $_.sourceFullPath -like "*.xyz" } + $excludedNonExistent | Should -BeNullOrEmpty + } + + It 'GetFilesToUpdate handles overlapping include patterns with different destinations' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @( + @{ filter = "test.txt"; destinationFolder = "folder1" } + @{ filter = "test.txt"; destinationFolder = "folder2" } + ) + filesToExclude = @() + } + } + + $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") } + $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') + } +} + +Describe "GetFilesToUpdate (real template)" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'realPTETemplateFolder', Justification = 'False positive.')] + $realPTETemplateFolder = Join-Path $PSScriptRoot "../Templates/Per Tenant Extension" -Resolve + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'realAppSourceAppTemplateFolder', Justification = 'False positive.')] + $realAppSourceAppTemplateFolder = Join-Path $PSScriptRoot "../Templates/AppSource App" -Resolve + } + + It 'Return the correct files to exclude when type is PTE and powerPlatformSolutionFolder is not empty' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = "PowerPlatformSolution" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + + $filesToInclude | Should -Not -BeNullOrEmpty + $filesToInclude.Count | Should -Be 24 + $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") + + # No files to remove + $filesToExclude | Should -BeNullOrEmpty + } + + It 'Return PP files in filesToExclude when type is PTE but powerPlatformSolutionFolder is empty' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = '' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + + $filesToInclude | Should -Not -BeNullOrEmpty + $filesToInclude.Count | Should -Be 21 + + $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") + } + + # 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[2].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder ".github/workflows/PullPowerPlatformChanges.yaml") + $filesToExclude[2].destinationFullPath | Should -Be (Join-Path 'baseFolder' ".github/workflows/PullPowerPlatformChanges.yaml") + + } + + It 'Return the correct files when unusedALGoSystemFiles is specified' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = "PowerPlatformSolution" + unusedALGoSystemFiles = @("Test Next Major.settings.json") + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + + $filesToInclude | Should -Not -BeNullOrEmpty + $filesToInclude.Count | Should -Be 23 + + # Two files to remove + $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') + } + + It 'Return the correct files when unusedALGoSystemFiles is specified and no PP solution is present' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = '' + unusedALGoSystemFiles = @("Test Next Major.settings.json", "_BuildPowerPlatformSolution.yaml") + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + + $filesToInclude | Should -Not -BeNullOrEmpty + $filesToInclude.Count | Should -Be 20 + + # Four files to remove + $filesToExclude | Should -Not -BeNullOrEmpty + $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") + } + + It 'Returns the custom template settings files when there is a custom template' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = "PowerPlatformSolution" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + } + } + + $customTemplateFolder = $realPTETemplateFolder + $originalTemplateFolder = $realAppSourceAppTemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template + + $filesToInclude | Should -Not -BeNullOrEmpty + + # Check repo settings files + $repoSettingsFiles = $filesToInclude | Where-Object { $_.sourceFullPath -eq (Join-Path $customTemplateFolder ".github/AL-Go-Settings.json") } + + $repoSettingsFiles | Should -Not -BeNullOrEmpty + $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].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].type | Should -Be '' + + # Check project settings files + $projectSettingsFilesFromCustomTemplate = @($filesToInclude | Where-Object { $_.sourceFullPath -eq (Join-Path $customTemplateFolder ".AL-Go/settings.json") }) + + $projectSettingsFilesFromCustomTemplate | Should -Not -BeNullOrEmpty + $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].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].type | Should -Be '' + + # No files to exclude + $filesToExclude | Should -BeNullOrEmpty + } + + It 'GetFilesToUpdate handles AppSource template type correctly' { + $settings = @{ + type = "AppSource App" + powerPlatformSolutionFolder = '' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + } + } + + $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 + + $ppFiles = $filesToInclude | Where-Object { $_.sourceFullPath -like "*BuildPowerPlatformSolution*" } + $ppFiles | Should -BeNullOrEmpty + + # No files to remove that match PP files as they are not in the template + $ppExcludes = $filesToExclude | Where-Object { $_.sourceFullPath -like "*BuildPowerPlatformSolution*" } + $ppExcludes | Should -BeNullOrEmpty + } + + It 'GetFilesToUpdate with empty unusedALGoSystemFiles array does not exclude any files' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = '' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + } + } + + $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 + } + + It 'GetFilesToUpdate marks settings files with correct type' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = '' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + } + } + + $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" }) + $repoSettingsFiles | Should -Not -BeNullOrEmpty + $repoSettingsFiles[0].type | Should -Be 'settings' + + $projectSettingsFiles = @($filesToInclude | Where-Object { $_.sourceFullPath -like "*$ALGoSettingsFileName" -and $_.destinationFullPath -like "*Project1*.AL-Go*" }) + $projectSettingsFiles | Should -Not -BeNullOrEmpty + $projectSettingsFiles[0].type | Should -Be 'settings' + } + + It 'GetFilesToUpdate handles multiple projects correctly' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = '' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + } + } + + $projects = @('ProjectA', 'ProjectB', 'ProjectC') + $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*" } + $projectBSettings = $filesToInclude | Where-Object { $_.destinationFullPath -like "*ProjectB*.AL-Go*" } + $projectCSettings = $filesToInclude | Where-Object { $_.destinationFullPath -like "*ProjectC*.AL-Go*" } + + $projectASettings | Should -Not -BeNullOrEmpty + $projectBSettings | Should -Not -BeNullOrEmpty + $projectCSettings | Should -Not -BeNullOrEmpty + } + + It 'GetFilesToUpdate excludes files correctly when in both unusedALGoSystemFiles and filesToExclude' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = '' + unusedALGoSystemFiles = @("Test Next Major.settings.json") + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @(@{ filter = "Test Next Major.settings.json" }) + } + } + + $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" } + $testNextMajor | Should -BeNullOrEmpty + + # Should be in exclude list + $excludedTestNextMajor = @($filesToExclude | Where-Object { $_.sourceFullPath -like "*Test Next Major.settings.json" }) + $excludedTestNextMajor | Should -Not -BeNullOrEmpty + $excludedTestNextMajor.Count | Should -Be 1 + } +} diff --git a/Tests/ReadSettings.Test.ps1 b/Tests/ReadSettings.Test.ps1 index 8443a5fb1..24fdb69c9 100644 --- a/Tests/ReadSettings.Test.ps1 +++ b/Tests/ReadSettings.Test.ps1 @@ -261,14 +261,14 @@ InModuleScope ReadSettings { # Allows testing of private functions It 'Default settings match schema' -Skip:($PSVersionTable.PSVersion.Major -lt 7) { $defaultSettings = GetDefaultSettings - Test-Json -json (ConvertTo-Json $defaultSettings) -schema $schema | Should -Be $true + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema | Should -Be $true } It 'Shell setting can only be pwsh or powershell' -Skip:($PSVersionTable.PSVersion.Major -lt 7) { $defaultSettings = GetDefaultSettings $defaultSettings.shell = 42 try { - Test-Json -json (ConvertTo-Json $defaultSettings) -schema $schema + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema } catch { $_.Exception.Message | Should -Be "The JSON is not valid with the schema: Value is `"integer`" but should be `"string`" at '/shell'" @@ -276,7 +276,7 @@ InModuleScope ReadSettings { # Allows testing of private functions $defaultSettings.shell = "random" try { - Test-Json -json (ConvertTo-Json $defaultSettings) -schema $schema + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema } catch { $_.Exception.Message | Should -Be "The JSON is not valid with the schema: The string value is not a match for the indicated regular expression at '/shell'" @@ -288,7 +288,7 @@ InModuleScope ReadSettings { # Allows testing of private functions $defaultSettings = GetDefaultSettings $defaultSettings.projects = "not an array" try { - Test-Json -json (ConvertTo-Json $defaultSettings) -schema $schema + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema } catch { $_.Exception.Message | Should -Be "The JSON is not valid with the schema: Value is `"string`" but should be `"array`" at '/projects'" @@ -297,7 +297,7 @@ InModuleScope ReadSettings { # Allows testing of private functions # If the projects setting is an array, but contains non-string values, it should throw an error $defaultSettings.projects = @("project1", 42) try { - Test-Json -json (ConvertTo-Json $defaultSettings) -schema $schema + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema } catch { $_.Exception.Message | Should -Be "The JSON is not valid with the schema: Value is `"integer`" but should be `"string`" at '/projects/1'" @@ -305,9 +305,9 @@ InModuleScope ReadSettings { # Allows testing of private functions # If the projects setting is an array of strings, it should pass the schema validation $defaultSettings.projects = @("project1") - Test-Json -json (ConvertTo-Json $defaultSettings) -schema $schema | Should -Be $true + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema | Should -Be $true $defaultSettings.projects = @("project1", "project2") - Test-Json -json (ConvertTo-Json $defaultSettings) -schema $schema | Should -Be $true + Test-Json -json (ConvertTo-Json $defaultSettings -Depth 99) -schema $schema | Should -Be $true } It 'overwriteSettings property resets settings from destination object (simple types)' { diff --git a/e2eTests/scenarios/CustomTemplate/runtest.ps1 b/e2eTests/scenarios/CustomTemplate/runtest.ps1 index 9b7594653..5cdef971b 100644 --- a/e2eTests/scenarios/CustomTemplate/runtest.ps1 +++ b/e2eTests/scenarios/CustomTemplate/runtest.ps1 @@ -55,7 +55,7 @@ $template = "https://github.com/$pteTemplate" # Login SetTokenAndRepository -github:$github -githubOwner $githubOwner -appId $e2eAppId -appKey $e2eAppKey -repository $repository -# Create tempolate repository +# Create template repository CreateAlGoRepository ` -github:$github ` -linux:$linux ` @@ -143,6 +143,40 @@ $customJobs = @( $cicdYaml.AddCustomJobsToYaml($customJobs, [CustomizationOrigin]::FinalRepository) # In the context of the template repository, these custom jobs are treated as final customizations $cicdYaml.Save($cicdWorkflow) +# Add a custom workflow file in the template repository (to be copied to the final repository, as workflow files are always propagated) +$customWorkflowfileRelativePath = '.github/workflows/CustomWorkflow.yaml' +$customWorkflowFile = Join-Path $templateRepoPath $customWorkflowfileRelativePath +$customWorkflowContent = @" +name: Custom Workflow + +on: + push: + branches: + - main + +jobs: + CustomJob: + runs-on: [ windows-latest ] + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Run Custom Script + run: | + Write-Host 'Custom Workflow was triggered!' +"@ +Set-Content -Path $customWorkflowFile -Value $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' +} + +# 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 + # Push CommitAndPush -commitMessage 'Add template customizations' @@ -227,6 +261,29 @@ Pull (Join-Path (Get-Location) $CustomTemplateRepoSettingsFile) | Should -Exist (Join-Path (Get-Location) $CustomTemplateProjectSettingsFile) | Should -Exist +# 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 custom file is NOT present +(Join-Path (Get-Location) $customFileName) | Should -Not -Exist # Custom file should not be copied by default + +# Add custom file to be copied via settings +$null = Add-PropertiesToJsonFile -path '.github/AL-Go-Settings.json' -properties @{ "customALGoFiles" = @{ "filesToInclude" = @( @{ "filter" = $customFileName } ) } } + +# Push +CommitAndPush -commitMessage 'Add custom file to be updated when updating AL-Go system files [skip ci]' + +# Update AL-Go System Files to uptake custom file +RunUpdateAlGoSystemFiles -directCommit -wait -templateUrl $templateRepository -ghTokenWorkflow $algoauthapp -repository $repository -branch $branch | Out-Null + +# 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") + # Run CICD $run = RunCICD -repository $repository -branch $branch -wait