diff --git a/Actions/.Modules/ReadSettings.psm1 b/Actions/.Modules/ReadSettings.psm1 index c0a3bb040..8c80cbb36 100644 --- a/Actions/.Modules/ReadSettings.psm1 +++ b/Actions/.Modules/ReadSettings.psm1 @@ -241,6 +241,7 @@ function GetDefaultSettings "gitSubmodulesTokenSecretName" = "gitSubmodulesToken" "shortLivedArtifactsRetentionDays" = 1 # 0 means use GitHub default "reportSuppressedDiagnostics" = $false + "workflowDefaultInputs" = @() } } diff --git a/Actions/.Modules/settings.schema.json b/Actions/.Modules/settings.schema.json index b498eb32b..4e36caa21 100644 --- a/Actions/.Modules/settings.schema.json +++ b/Actions/.Modules/settings.schema.json @@ -693,6 +693,23 @@ "reportSuppressedDiagnostics": { "type": "boolean", "description": "Report suppressed diagnostics. See https://aka.ms/ALGoSettings#reportsuppresseddiagnostics" + }, + "workflowDefaultInputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the workflow input" + }, + "value": { + "description": "The default value for the workflow input (can be string, boolean, or number)" + } + }, + "required": ["name", "value"] + }, + "description": "An array of workflow input default values. See https://aka.ms/ALGoSettings#workflowDefaultInputs" } } } diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 40f770bb3..4d264298c 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -301,6 +301,173 @@ function ModifyUpdateALGoSystemFiles { $yaml.Replace('jobs:/UpdateALGoSystemFiles:/', $updateALGoSystemFilesJob.content) } +function ApplyWorkflowDefaultInputs { + Param( + [Yaml] $yaml, + [hashtable] $repoSettings, + [string] $workflowName + ) + + # Check if workflow_dispatch inputs exist + $workflowDispatch = $yaml.Get('on:/workflow_dispatch:/') + if (-not $workflowDispatch) { + # No workflow_dispatch section, nothing to do + return + } + + $inputs = $workflowDispatch.Get('inputs:/') + if (-not $inputs) { + # No inputs section, nothing to do + return + } + + if ($repoSettings.workflowDefaultInputs.Count -eq 0) { + # No defaults for this workflow + return + } + + # Apply defaults to matching inputs + foreach ($default in $repoSettings.workflowDefaultInputs) { + $inputName = $default.name + $defaultValue = $default.value + + # Check if this input exists in the workflow + $inputSection = $inputs.Get("$($inputName):/") + if (-not $inputSection) { + # Input is not present in the workflow + continue + } + + # Get the input type from the YAML if specified + $inputType = $null + $typeStart = 0 + $typeCount = 0 + if ($inputSection.Find('type:', [ref] $typeStart, [ref] $typeCount)) { + $typeLine = $inputSection.content[$typeStart].Trim() + if ($typeLine -match 'type:\s*(.+)') { + $inputType = $matches[1].Trim() + } + } + + # Validate that the value type matches the input type + $validationError = $null + if ($inputType) { + switch ($inputType) { + 'boolean' { + if ($defaultValue -isnot [bool]) { + $validationError = "Workflow '$workflowName', input '$inputName': Expected boolean value, but got $($defaultValue.GetType().Name). Please use `$true or `$false." + } + } + 'number' { + if ($defaultValue -isnot [int] -and $defaultValue -isnot [long] -and $defaultValue -isnot [double]) { + $validationError = "Workflow '$workflowName', input '$inputName': Expected number value, but got $($defaultValue.GetType().Name)." + } + } + 'string' { + if ($defaultValue -isnot [string]) { + $validationError = "Workflow '$workflowName', input '$inputName': Expected string value, but got $($defaultValue.GetType().Name)." + } + } + 'choice' { + # Choice inputs accept strings and must match one of the available options (case-sensitive) + if ($defaultValue -isnot [string]) { + $validationError = "Workflow '$workflowName', input '$inputName': Expected string value for choice input, but got $($defaultValue.GetType().Name)." + } + else { + # Validate that the value is one of the available options + $optionsStart = 0 + $optionsCount = 0 + if ($inputSection.Find('options:', [ref] $optionsStart, [ref] $optionsCount)) { + $availableOptions = @() + # Parse the options from the YAML (they are indented list items starting with "- ") + for ($i = $optionsStart + 1; $i -lt ($optionsStart + $optionsCount); $i++) { + $optionLine = $inputSection.content[$i].Trim() + if ($optionLine -match '^-\s*(.+)$') { + $availableOptions += $matches[1].Trim() + } + } + + if ($availableOptions.Count -gt 0 -and $availableOptions -cnotcontains $defaultValue) { + $validationError = "Workflow '$workflowName', input '$inputName': Value '$defaultValue' is not a valid choice (case-sensitive match required). Available options: $($availableOptions -join ', ')." + } + } + } + } + } + } + else { + # If no type is specified in the workflow, it defaults to string + if ($defaultValue -isnot [string]) { + OutputWarning "Workflow '$workflowName', input '$inputName': No type specified in workflow (defaults to string), but configured value is $($defaultValue.GetType().Name). This may cause issues." + } + } + + if ($validationError) { + throw $validationError + } + + # Convert the default value to the appropriate YAML format + $yamlValue = $defaultValue + if ($defaultValue -is [bool]) { + $yamlValue = $defaultValue.ToString().ToLower() + } + elseif ($defaultValue -is [string]) { + # Quote strings and escape single quotes per YAML spec + $escapedValue = $defaultValue.Replace("'", "''") + $yamlValue = "'$escapedValue'" + } + + # Find and replace the default: line in the input section + $start = 0 + $count = 0 + if ($inputSection.Find('default:', [ref] $start, [ref] $count)) { + # Replace existing default value + $inputSection.Replace('default:', "default: $yamlValue") + } + else { + # Add default value - find the best place to insert it + # Insert after type, required, or description (whichever comes last) + $insertAfter = -1 + $typeLine = 0 + $typeCount = 0 + $requiredLine = 0 + $requiredCount = 0 + $descLine = 0 + $descCount = 0 + + if ($inputSection.Find('type:', [ref] $typeLine, [ref] $typeCount)) { + $insertAfter = $typeLine + $typeCount + } + if ($inputSection.Find('required:', [ref] $requiredLine, [ref] $requiredCount)) { + if (($requiredLine + $requiredCount) -gt $insertAfter) { + $insertAfter = $requiredLine + $requiredCount + } + } + if ($inputSection.Find('description:', [ref] $descLine, [ref] $descCount)) { + if (($descLine + $descCount) -gt $insertAfter) { + $insertAfter = $descLine + $descCount + } + } + + if ($insertAfter -eq -1) { + # No other properties, insert at position 1 (after the input name) + $insertAfter = 1 + } + + $inputSection.Insert($insertAfter, "default: $yamlValue") + } + + # Update the inputs section with the modified input + $inputs.Replace("$($inputName):/", $inputSection.content) + } + + # Update the workflow_dispatch section with modified inputs + $workflowDispatch.Replace('inputs:/', $inputs.content) + + # Update the on: section with modified workflow_dispatch + $yaml.Replace('on:/workflow_dispatch:/', $workflowDispatch.content) +} + function GetWorkflowContentWithChangesFromSettings { Param( [string] $srcFile, @@ -394,6 +561,11 @@ function GetWorkflowContentWithChangesFromSettings { ModifyUpdateALGoSystemFiles -yaml $yaml -repoSettings $repoSettings } + # Apply workflow input defaults from settings + if ($repoSettings.Keys -contains 'workflowDefaultInputs') { + ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName $workflowName + } + # combine all the yaml file lines into a single string with LF line endings $yaml.content -join "`n" } diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 2481179bb..0c5a1f323 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,3 +1,45 @@ +### 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. + +When you add this setting to your AL-Go settings file and run the "Update AL-Go System Files" workflow, the default values will be automatically applied to the workflow YAML files in your repository. +The default values must match the input types (boolean, number, string, or choice) defined in the workflow YAML files. + +Example configuration: + +```json +{ + "workflowDefaultInputs": [ + { "name": "directCommit", "value": true }, + { "name": "useGhTokenWorkflow", "value": true } + ] +} +``` + +This setting can be used on its own in repository settings to apply defaults to all workflows with matching input names. Alternatively, you can use it within [conditional settings](https://aka.ms/algosettings#conditional-settings) to apply defaults only to specific workflows, branches, or other conditions. + +Example using conditional settings to target specific workflows: + +```json +{ + "conditionalSettings": [ + { + "workflows": ["Create Release"], + "settings": { + "workflowDefaultInputs": [ + { "name": "directCommit", "value": true }, + { "name": "releaseType", "value": "Prerelease" } + ] + } + } + ] +} +``` + +**Important:** When multiple conditional settings blocks match and both define `workflowDefaultInputs`, the arrays are merged following AL-Go's standard behavior for complex setting types (all entries are kept). If the same input name appears in multiple entries, the last matching entry takes precedence. + +Read more at [workflowDefaultInputs](https://aka.ms/algosettings#workflowDefaultInputs). + ### Issues - Issue 1961 KeyVault access in PR pipeline @@ -7,6 +49,7 @@ - Use Runner_Temp instead of GetTempFolder whenever possible - Issue 2016 Running Update AL-Go system files with branches wildcard `*` tries to update _origin_ - Issue 1960 Deploy Reference Documentation fails +- Discussion 1952 Set default values on workflow_dispatch input ## v8.0 diff --git a/Scenarios/settings.md b/Scenarios/settings.md index 48ea6ab0c..5fe3bba5b 100644 --- a/Scenarios/settings.md +++ b/Scenarios/settings.md @@ -83,6 +83,7 @@ The repository settings are only read from the repository settings file (.github | useGitSubmodules | If your repository is using Git Submodules, you can set the `useGitSubmodules` setting to `"true"` or `"recursive"` in order to use these submodules during build workflows. If `useGitSubmodules` is not set, git submodules are not initialized. If the submodules reside in private repositories, you need to define a `gitSubmodulesToken` secret. Read [this](https://aka.ms/algosecrets#gitSubmodulesToken) for more information. | | commitOptions | If you want more control over how AL-Go creates pull requests or commits changes to the repository you can define `commitOptions`. It is a structure defining how you want AL-Go to handle automated commits or pull requests coming from AL-Go (e.g. for Update AL-Go System Files). The structure contains the following properties:
**messageSuffix** = A string you want to append to the end of commits/pull requests created by AL-Go. This can be useful if you are using the Azure Boards integration (or similar integration) to link commits to work items.
`createPullRequest` : A boolean defining whether AL-Go should create a pull request or attempt to push directly in the branch.
**pullRequestAutoMerge** = A boolean defining whether you want AL-Go pull requests to be set to auto-complete. This will auto-complete the pull requests once all checks are green and all required reviewers have approved.
**pullRequestMergeMethod** = A string defining which merge method to use when auto-merging pull requests. Valid values are "merge" and "squash". Default is "squash".
**pullRequestLabels** = A list of labels to add to the pull request. The labels need to be created in the repository before they can be applied.
If you want different behavior in different AL-Go workflows you can add the `commitOptions` setting to your [workflow-specific settings files](https://github.com/microsoft/AL-Go/blob/main/Scenarios/settings.md#where-are-the-settings-located). | | incrementalBuilds | A structure defining how you want AL-Go to handle incremental builds. When using incremental builds for a build, AL-Go will look for the latest successful CI/CD build, newer than the defined `retentionDays` and only rebuild projects or apps (based on `mode`) which needs to be rebuilt. The structure supports the following properties:
**onPush** = Determines whether incremental builds is enabled in CI/CD triggered by a merge/push event. Default is **false**.
**onPull_Request** = Determines whether incremental builds is enabled in Pull Requests. Default is **true**.
**onSchedule** = Determines whether incremental builds is enabled in CI/CD when running on a schedule. Default is **false**.
**retentionDays** = Number of days a successful build is good (and can be used for incremental builds). Default is **30**.
**mode** = Specifies the mode for incremental builds. Currently, two values are supported. Use **modifiedProjects** when you want to rebuild all apps in all modified projects and depending projects or **modifiedApps** if you want to rebuild modified apps and all apps with dependencies to this app.
**NOTE:** when running incremental builds, it is recommended to also set `workflowConcurrency` for the CI/CD workflow, as defined [here](https://aka.ms/algosettings#workflowConcurrency). | +| workflowDefaultInputs | An array of workflow input default values. This setting allows you to configure default values for workflow_dispatch inputs, making it easier to run workflows manually with consistent settings. Each entry should contain:
  **name** = The name of the workflow input
  **value** = The default value (can be string, boolean, or number)
**Important validation rules:**
  • The value type must match the input type defined in the workflow YAML file (boolean, number, string, or choice)
  • For choice inputs, the value must be one of the options declared in the workflow
  • Choice validation is case-sensitive
Type and choice validation is performed when running the "Update AL-Go System Files" workflow to prevent configuration errors.
When you run the "Update AL-Go System Files" workflow, these default values will be applied to all workflows that have matching input names.
**Usage:** This setting can be used on its own in repository settings to apply defaults to all workflows with matching input names. Alternatively, you can use it within [conditional settings](#conditional-settings) to apply defaults only to specific workflows, branches, or other conditions.
**Important:** When multiple conditional settings blocks match and both define `workflowDefaultInputs`, the arrays are merged (all entries are kept). When the defaults are applied to workflows, the last matching entry for each input name wins.
**Example:**
`"workflowDefaultInputs": [`
` { "name": "directCommit", "value": true },`
` { "name": "useGhTokenWorkflow", "value": true },`
` { "name": "updateVersionNumber", "value": "+0.1" }`
`]` | @@ -181,7 +182,9 @@ to your [project settings file](#where-are-the-settings-located) will ensure tha - **workflows** settings will be applied to workflows matching the patterns - **users** settings will be applied for users matching the patterns -You could imagine that you could have and organizational settings variable containing: +**Note:** You can use `workflowDefaultInputs` within conditional settings to apply workflow input defaults only when certain conditions are met. For example, you could set different default values for specific workflows or branches. + +You could imagine that you could have an organizational settings variable containing: ```json "ConditionalSettings": [ @@ -204,6 +207,12 @@ Which will ensure that for all repositories named `bcsamples-*` in this organiza > [!NOTE] > You can have conditional settings on any level and all conditional settings which has all conditions met will be applied in the order of settings file + appearance. + + +### Workflow Name Sanitization + +When matching workflow names for conditional settings, AL-Go sanitizes the actual workflow name before comparison. Sanitization removes invalid filename characters such as leading spaces, quotes, colons, slashes, and other special characters. For example, a workflow named `" CI/CD"` would be sanitized to `"CICD"` for matching purposes. + # Expert level diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index ee498dd2a..8455e9330 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -1,270 +1,1109 @@ -Get-Module TestActionsHelper | Remove-Module -Force -Import-Module (Join-Path $PSScriptRoot 'TestActionsHelper.psm1') -Import-Module (Join-Path $PSScriptRoot "..\Actions\TelemetryHelper.psm1") -$errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0 - -Describe "CheckForUpdates Action Tests" { - BeforeAll { - $actionName = "CheckForUpdates" - $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'actionScript', Justification = 'False positive.')] - $actionScript = GetActionScript -scriptRoot $scriptRoot -scriptName "$actionName.ps1" - } - - It 'Compile Action' { - Invoke-Expression $actionScript - } - - It 'Test action.yaml matches script' { - $outputs = [ordered]@{ - } - YamlTest -scriptRoot $scriptRoot -actionName $actionName -actionScript $actionScript -outputs $outputs - } - - It 'Test that Update AL-Go System Files uses fixes runs-on' { - . (Join-Path $scriptRoot "yamlclass.ps1") - - $updateYamlFile = Join-Path $scriptRoot "..\..\Templates\Per Tenant Extension\.github\workflows\UpdateGitHubGoSystemFiles.yaml" - $updateYaml = [Yaml]::Load($updateYamlFile) - $updateYaml.content | Where-Object { $_ -like '*runs-on:*' } | ForEach-Object { - $_.Trim() | Should -Be 'runs-on: windows-latest' -Because "Expected 'runs-on: windows-latest', in order to hardcode runner to windows-latest, but got $_" - } - } -} - -Describe('YamlClass Tests') { - BeforeAll { - $actionName = "CheckForUpdates" - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'scriptRoot', Justification = 'False positive.')] - $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve - - Mock Trace-Information {} - } - - It 'Test YamlClass' { - . (Join-Path $scriptRoot "yamlclass.ps1") - $yaml = [Yaml]::load((Join-Path $PSScriptRoot 'YamlSnippet.txt')) - - # Yaml file should have 77 entries - $yaml.content.Count | Should -be 74 - - $start = 0; $count = 0 - # Locate lines for permissions section (including permissions: line) - $yaml.Find('permissions:', [ref] $start, [ref] $count) | Should -be $true - $start | Should -be 17 - $count | Should -be 5 - - # Locate lines for permissions section (excluding permissions: line) - $yaml.Find('permissions:/', [ref] $start, [ref] $count) | Should -be $true - $start | Should -be 18 - $count | Should -be 4 - - # Get Yaml class for permissions section (excluding permissions: line) - $yaml.Get('permissions:/').content | ForEach-Object { $_ | Should -not -belike ' *' } - - # Locate section called permissionos (should return false) - $yaml.Find('permissionos:', [ref] $start, [ref] $count) | Should -Not -be $true - - # Check checkout step - ($yaml.Get('jobs:/Initialization:/steps:/- name: Checkout').content -join '') | Should -be "- name: Checkout uses: actions/checkout@v4 with: lfs: true" - - # Get Shell line in read Settings step - ($yaml.Get('jobs:/Initialization:/steps:/- name: Read settings/with:/shell:').content -join '') | Should -be "shell: powershell" - - # Get Jobs section (without the jobs: line) - $jobsYaml = $yaml.Get('jobs:/') - - # Locate CheckForUpdates - $jobsYaml.Find('CheckForUpdates:', [ref] $start, [ref] $count) | Should -be $true - $start | Should -be 24 - $count | Should -be 19 - - # Replace all occurances of 'shell: powershell' with '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.content[44].Trim() | Should -be 'shell: pwsh' - $yaml.content.Count | Should -be 72 - - # Get Jobs section (without the jobs: line) - $jobsYaml = $yaml.Get('jobs:/') - ($jobsYaml.Get('Initialization:/steps:/- name: Read settings/with:/shell:').content -join '') | Should -be "shell: pwsh" - } - - It 'Test YamlClass Remove' { - . (Join-Path $scriptRoot "yamlclass.ps1") - - $yamlSnippet = @( - "permissions:", - " contents: read", - " actions: read", - " pull-requests: write", - " checks: write" - ) - - $permissionsYaml = [Yaml]::new($yamlSnippet) - - $permissionsContent = $permissionsYaml.Get('permissions:/') - $permissionsContent.content.Count | Should -be 4 - $permissionsContent.Remove(1, 0) # Remove nothing - $permissionsContent.content.Count | Should -be 4 - $permissionsContent.content[0].Trim() | Should -be 'contents: read' - $permissionsContent.content[1].Trim() | Should -be 'actions: read' - $permissionsContent.content[2].Trim() | Should -be 'pull-requests: write' - $permissionsContent.content[3].Trim() | Should -be 'checks: write' - - $permissionsContent = $permissionsYaml.Get('permissions:/') - $permissionsContent.content.Count | Should -be 4 - $permissionsContent.Remove(0, 3) # Remove first 3 lines - $permissionsContent.content.Count | Should -be 1 - $permissionsContent.content[0].Trim() | Should -be 'checks: write' - - $permissionsContent = $permissionsYaml.Get('permissions:/') - $permissionsContent.content.Count | Should -be 4 - $permissionsContent.Remove(2, 1) # Remove only the 3rd line - $permissionsContent.content.Count | Should -be 3 - $permissionsContent.content[0].Trim() | Should -be 'contents: read' - $permissionsContent.content[1].Trim() | Should -be 'actions: read' - $permissionsContent.content[2].Trim() | Should -be 'checks: write' - - $permissionsContent = $permissionsYaml.Get('permissions:/') - $permissionsContent.content.Count | Should -be 4 - $permissionsContent.Remove(2, 4) # Remove more than the number of lines - $permissionsContent.content.Count | Should -be 2 # Only the first two lines should remain - $permissionsContent.content[0].Trim() | Should -be 'contents: read' - $permissionsContent.content[1].Trim() | Should -be 'actions: read' - } - - It 'Test YamlClass GetCustomJobsFromYaml' { - . (Join-Path $scriptRoot "yamlclass.ps1") - - $customizedYaml = [Yaml]::load((Join-Path $PSScriptRoot 'CustomizedYamlSnippet-All.txt')) - $nonCustomizedYaml = [Yaml]::load((Join-Path $PSScriptRoot 'YamlSnippet.txt')) - - # Get Custom jobs from yaml - $customJobs = $customizedYaml.GetCustomJobsFromYaml('CustomJob*') - $customJobs | Should -Not -BeNullOrEmpty - $customJobs.Count | Should -be 2 - - $customJobs[0].Name | Should -Be 'CustomJob-MyFinalJob' - $customJobs[0].Origin | Should -Be 'FinalRepository' - - $customJobs[1].Name | Should -Be 'CustomJob-MyCustomTemplateJob' - $customJobs[1].Origin | Should -Be 'TemplateRepository' - - $emptyCustomJobs = $nonCustomizedYaml.GetCustomJobsFromYaml('CustomJob*') - $emptyCustomJobs | Should -BeNullOrEmpty - } - - It 'Test YamlClass AddCustomJobsToYaml' { - . (Join-Path $scriptRoot "yamlclass.ps1") - - $customTemplateYaml = [Yaml]::load((Join-Path $PSScriptRoot 'CustomizedYamlSnippet-TemplateRepository.txt')) - $finalRepositoryYaml = [Yaml]::load((Join-Path $PSScriptRoot 'CustomizedYamlSnippet-FinalRepository.txt')) - $nonCustomizedYaml = [Yaml]::load((Join-Path $PSScriptRoot 'YamlSnippet.txt')) - - $customTemplateJobs = $customTemplateYaml.GetCustomJobsFromYaml('CustomJob*') - $customTemplateJobs | Should -Not -BeNullOrEmpty - $customTemplateJobs.Count | Should -be 1 - $customTemplateJobs[0].Name | Should -Be 'CustomJob-MyCustomTemplateJob' - $customTemplateJobs[0].Origin | Should -Be 'FinalRepository' # Custom template job has FinalRepository as origin when in the template itself - - # Add the custom job to the non-customized yaml - $nonCustomizedYaml.AddCustomJobsToYaml($customTemplateJobs, [CustomizationOrigin]::TemplateRepository) - - $nonCustomizedYaml.content -join "`r`n" | Should -Be ($finalRepositoryYaml.content -join "`r`n") - - # Adding the jobs again doesn't have an effect - $nonCustomizedYaml.AddCustomJobsToYaml($customTemplateJobs, [CustomizationOrigin]::TemplateRepository) - - $nonCustomizedYaml.content -join "`r`n" | Should -Be ($finalRepositoryYaml.content -join "`r`n") - } - - It('Test YamlClass ApplyTemplateCustomizations') { - . (Join-Path $scriptRoot "yamlclass.ps1") - - $srcContent = Get-Content (Join-Path $PSScriptRoot 'YamlSnippet.txt') - $resultContent = Get-Content (Join-Path $PSScriptRoot 'CustomizedYamlSnippet-FinalRepository.txt') - - [Yaml]::ApplyTemplateCustomizations([ref] $srcContent, (Join-Path $PSScriptRoot 'CustomizedYamlSnippet-TemplateRepository.txt')) - - $srcContent | Should -Be ($resultContent -join "`n") - } - - It('Test YamlClass ApplyFinalCustomizations') { - . (Join-Path $scriptRoot "yamlclass.ps1") - - $srcContent = Get-Content (Join-Path $PSScriptRoot 'YamlSnippet.txt') - $resultContent = Get-Content (Join-Path $PSScriptRoot 'CustomizedYamlSnippet-TemplateRepository.txt') - - [Yaml]::ApplyFinalCustomizations([ref] $srcContent, (Join-Path $PSScriptRoot 'CustomizedYamlSnippet-TemplateRepository.txt')) # Threat the template repo as a final repo - - $srcContent | Should -Be ($resultContent -join "`n") - } -} - -Describe "CheckForUpdates Action: CheckForUpdates.HelperFunctions.ps1" { - BeforeAll { - $actionName = "CheckForUpdates" - $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.')] - $tmpSrcFile = Join-Path $PSScriptRoot "tempSrcFile.json" - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'actionScript', Justification = 'False positive.')] - $tmpDstFile = Join-Path $PSScriptRoot "tempDestFile.json" - } - - AfterEach { - # Clean up temporary files - if (Test-Path $tmpSrcFile) { - Remove-Item -Path $tmpSrcFile -Force - } - if (Test-Path $tmpDstFile) { - Remove-Item -Path $tmpDstFile -Force - } - } - - It 'GetModifiedSettingsContent returns correct content when destination file is not empty' { - # Create settings files with the content - @{ "`$schema" = "someSchema"; "srcSetting" = "value1" } | ConvertTo-Json -Depth 10 | Out-File -FilePath $tmpSrcFile -Force - @{ "setting1" = "value2" } | ConvertTo-Json -Depth 10 | Out-File -FilePath $tmpDstFile -Force - - $modifiedContentJson = GetModifiedSettingsContent -srcSettingsFile $tmpSrcFile -dstSettingsFile $tmpDstFile - - $modifiedContent = $modifiedContentJson | ConvertFrom-Json - $modifiedContent | Should -Not -BeNullOrEmpty - $modifiedContent.PSObject.Properties.Name.Count | Should -Be 2 # setting1 and $schema - $modifiedContent."setting1" | Should -Be "value2" - $modifiedContent."`$schema" | Should -Be "someSchema" - } - - It 'GetModifiedSettingsContent returns correct content when destination file is empty' { - # Create only the source file - @{ "`$schema" = "someSchema"; "srcSetting" = "value1" } | ConvertTo-Json -Depth 10 | Out-File -FilePath $tmpSrcFile -Force - '' | Out-File -FilePath $tmpDstFile -Force - $modifiedContentJson = GetModifiedSettingsContent -srcSettingsFile $tmpSrcFile -dstSettingsFile $tmpDstFile - - $modifiedContent = $modifiedContentJson | ConvertFrom-Json - $modifiedContent | Should -Not -BeNullOrEmpty - @($modifiedContent.PSObject.Properties.Name).Count | Should -Be 2 # srcSetting and $schema - $modifiedContent."`$schema" | Should -Be "someSchema" - $modifiedContent."srcSetting" | Should -Be "value1" - } - - It 'GetModifiedSettingsContent returns correct content when destination file does not exist' { - # Create only the source file - @{ "`$schema" = "someSchema"; "srcSetting" = "value1" } | ConvertTo-Json -Depth 10 | Out-File -FilePath $tmpSrcFile -Force - - Test-Path $tmpDstFile | Should -Be $false - $modifiedContentJson = GetModifiedSettingsContent -srcSettingsFile $tmpSrcFile -dstSettingsFile $tmpDstFile - - $modifiedContent = $modifiedContentJson | ConvertFrom-Json - $modifiedContent | Should -Not -BeNullOrEmpty - $modifiedContent.PSObject.Properties.Name.Count | Should -Be 2 # srcSetting and $schema - $modifiedContent."srcSetting" | Should -Be "value1" - $modifiedContent."`$schema" | Should -Be "someSchema" - } -} +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 +$errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0 + +Describe "CheckForUpdates Action Tests" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'actionScript', Justification = 'False positive.')] + $actionScript = GetActionScript -scriptRoot $scriptRoot -scriptName "$actionName.ps1" + } + + It 'Compile Action' { + Invoke-Expression $actionScript + } + + It 'Test action.yaml matches script' { + $outputs = [ordered]@{ + } + YamlTest -scriptRoot $scriptRoot -actionName $actionName -actionScript $actionScript -outputs $outputs + } + + It 'Test that Update AL-Go System Files uses fixes runs-on' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + $updateYamlFile = Join-Path $scriptRoot "..\..\Templates\Per Tenant Extension\.github\workflows\UpdateGitHubGoSystemFiles.yaml" + $updateYaml = [Yaml]::Load($updateYamlFile) + $updateYaml.content | Where-Object { $_ -like '*runs-on:*' } | ForEach-Object { + $_.Trim() | Should -Be 'runs-on: windows-latest' -Because "Expected 'runs-on: windows-latest', in order to hardcode runner to windows-latest, but got $_" + } + } +} + +Describe('YamlClass Tests') { + BeforeAll { + $actionName = "CheckForUpdates" + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'scriptRoot', Justification = 'False positive.')] + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + + Mock Trace-Information {} + } + + It 'Test YamlClass' { + . (Join-Path $scriptRoot "yamlclass.ps1") + $yaml = [Yaml]::load((Join-Path $PSScriptRoot 'YamlSnippet.txt')) + + # Yaml file should have 77 entries + $yaml.content.Count | Should -be 74 + + $start = 0; $count = 0 + # Locate lines for permissions section (including permissions: line) + $yaml.Find('permissions:', [ref] $start, [ref] $count) | Should -be $true + $start | Should -be 17 + $count | Should -be 5 + + # Locate lines for permissions section (excluding permissions: line) + $yaml.Find('permissions:/', [ref] $start, [ref] $count) | Should -be $true + $start | Should -be 18 + $count | Should -be 4 + + # Get Yaml class for permissions section (excluding permissions: line) + $yaml.Get('permissions:/').content | ForEach-Object { $_ | Should -not -belike ' *' } + + # Locate section called permissionos (should return false) + $yaml.Find('permissionos:', [ref] $start, [ref] $count) | Should -Not -be $true + + # Check checkout step + ($yaml.Get('jobs:/Initialization:/steps:/- name: Checkout').content -join '') | Should -be "- name: Checkout uses: actions/checkout@v4 with: lfs: true" + + # Get Shell line in read Settings step + ($yaml.Get('jobs:/Initialization:/steps:/- name: Read settings/with:/shell:').content -join '') | Should -be "shell: powershell" + + # Get Jobs section (without the jobs: line) + $jobsYaml = $yaml.Get('jobs:/') + + # Locate CheckForUpdates + $jobsYaml.Find('CheckForUpdates:', [ref] $start, [ref] $count) | Should -be $true + $start | Should -be 24 + $count | Should -be 19 + + # Replace all occurances of 'shell: powershell' with '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.content[44].Trim() | Should -be 'shell: pwsh' + $yaml.content.Count | Should -be 72 + + # Get Jobs section (without the jobs: line) + $jobsYaml = $yaml.Get('jobs:/') + ($jobsYaml.Get('Initialization:/steps:/- name: Read settings/with:/shell:').content -join '') | Should -be "shell: pwsh" + } + + It 'Test YamlClass Remove' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + $yamlSnippet = @( + "permissions:", + " contents: read", + " actions: read", + " pull-requests: write", + " checks: write" + ) + + $permissionsYaml = [Yaml]::new($yamlSnippet) + + $permissionsContent = $permissionsYaml.Get('permissions:/') + $permissionsContent.content.Count | Should -be 4 + $permissionsContent.Remove(1, 0) # Remove nothing + $permissionsContent.content.Count | Should -be 4 + $permissionsContent.content[0].Trim() | Should -be 'contents: read' + $permissionsContent.content[1].Trim() | Should -be 'actions: read' + $permissionsContent.content[2].Trim() | Should -be 'pull-requests: write' + $permissionsContent.content[3].Trim() | Should -be 'checks: write' + + $permissionsContent = $permissionsYaml.Get('permissions:/') + $permissionsContent.content.Count | Should -be 4 + $permissionsContent.Remove(0, 3) # Remove first 3 lines + $permissionsContent.content.Count | Should -be 1 + $permissionsContent.content[0].Trim() | Should -be 'checks: write' + + $permissionsContent = $permissionsYaml.Get('permissions:/') + $permissionsContent.content.Count | Should -be 4 + $permissionsContent.Remove(2, 1) # Remove only the 3rd line + $permissionsContent.content.Count | Should -be 3 + $permissionsContent.content[0].Trim() | Should -be 'contents: read' + $permissionsContent.content[1].Trim() | Should -be 'actions: read' + $permissionsContent.content[2].Trim() | Should -be 'checks: write' + + $permissionsContent = $permissionsYaml.Get('permissions:/') + $permissionsContent.content.Count | Should -be 4 + $permissionsContent.Remove(2, 4) # Remove more than the number of lines + $permissionsContent.content.Count | Should -be 2 # Only the first two lines should remain + $permissionsContent.content[0].Trim() | Should -be 'contents: read' + $permissionsContent.content[1].Trim() | Should -be 'actions: read' + } + + It 'Test YamlClass GetCustomJobsFromYaml' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + $customizedYaml = [Yaml]::load((Join-Path $PSScriptRoot 'CustomizedYamlSnippet-All.txt')) + $nonCustomizedYaml = [Yaml]::load((Join-Path $PSScriptRoot 'YamlSnippet.txt')) + + # Get Custom jobs from yaml + $customJobs = $customizedYaml.GetCustomJobsFromYaml('CustomJob*') + $customJobs | Should -Not -BeNullOrEmpty + $customJobs.Count | Should -be 2 + + $customJobs[0].Name | Should -Be 'CustomJob-MyFinalJob' + $customJobs[0].Origin | Should -Be 'FinalRepository' + + $customJobs[1].Name | Should -Be 'CustomJob-MyCustomTemplateJob' + $customJobs[1].Origin | Should -Be 'TemplateRepository' + + $emptyCustomJobs = $nonCustomizedYaml.GetCustomJobsFromYaml('CustomJob*') + $emptyCustomJobs | Should -BeNullOrEmpty + } + + It 'Test YamlClass AddCustomJobsToYaml' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + $customTemplateYaml = [Yaml]::load((Join-Path $PSScriptRoot 'CustomizedYamlSnippet-TemplateRepository.txt')) + $finalRepositoryYaml = [Yaml]::load((Join-Path $PSScriptRoot 'CustomizedYamlSnippet-FinalRepository.txt')) + $nonCustomizedYaml = [Yaml]::load((Join-Path $PSScriptRoot 'YamlSnippet.txt')) + + $customTemplateJobs = $customTemplateYaml.GetCustomJobsFromYaml('CustomJob*') + $customTemplateJobs | Should -Not -BeNullOrEmpty + $customTemplateJobs.Count | Should -be 1 + $customTemplateJobs[0].Name | Should -Be 'CustomJob-MyCustomTemplateJob' + $customTemplateJobs[0].Origin | Should -Be 'FinalRepository' # Custom template job has FinalRepository as origin when in the template itself + + # Add the custom job to the non-customized yaml + $nonCustomizedYaml.AddCustomJobsToYaml($customTemplateJobs, [CustomizationOrigin]::TemplateRepository) + + $nonCustomizedYaml.content -join "`r`n" | Should -Be ($finalRepositoryYaml.content -join "`r`n") + + # Adding the jobs again doesn't have an effect + $nonCustomizedYaml.AddCustomJobsToYaml($customTemplateJobs, [CustomizationOrigin]::TemplateRepository) + + $nonCustomizedYaml.content -join "`r`n" | Should -Be ($finalRepositoryYaml.content -join "`r`n") + } + + It('Test YamlClass ApplyTemplateCustomizations') { + . (Join-Path $scriptRoot "yamlclass.ps1") + + $srcContent = Get-Content (Join-Path $PSScriptRoot 'YamlSnippet.txt') + $resultContent = Get-Content (Join-Path $PSScriptRoot 'CustomizedYamlSnippet-FinalRepository.txt') + + [Yaml]::ApplyTemplateCustomizations([ref] $srcContent, (Join-Path $PSScriptRoot 'CustomizedYamlSnippet-TemplateRepository.txt')) + + $srcContent | Should -Be ($resultContent -join "`n") + } + + It('Test YamlClass ApplyFinalCustomizations') { + . (Join-Path $scriptRoot "yamlclass.ps1") + + $srcContent = Get-Content (Join-Path $PSScriptRoot 'YamlSnippet.txt') + $resultContent = Get-Content (Join-Path $PSScriptRoot 'CustomizedYamlSnippet-TemplateRepository.txt') + + [Yaml]::ApplyFinalCustomizations([ref] $srcContent, (Join-Path $PSScriptRoot 'CustomizedYamlSnippet-TemplateRepository.txt')) # Threat the template repo as a final repo + + $srcContent | Should -Be ($resultContent -join "`n") + } +} + +Describe "CheckForUpdates Action: CheckForUpdates.HelperFunctions.ps1" { + BeforeAll { + $actionName = "CheckForUpdates" + $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.')] + $tmpSrcFile = Join-Path $PSScriptRoot "tempSrcFile.json" + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'actionScript', Justification = 'False positive.')] + $tmpDstFile = Join-Path $PSScriptRoot "tempDestFile.json" + } + + AfterEach { + # Clean up temporary files + if (Test-Path $tmpSrcFile) { + Remove-Item -Path $tmpSrcFile -Force + } + if (Test-Path $tmpDstFile) { + Remove-Item -Path $tmpDstFile -Force + } + } + + It 'GetModifiedSettingsContent returns correct content when destination file is not empty' { + # Create settings files with the content + @{ "`$schema" = "someSchema"; "srcSetting" = "value1" } | ConvertTo-Json -Depth 10 | Out-File -FilePath $tmpSrcFile -Force + @{ "setting1" = "value2" } | ConvertTo-Json -Depth 10 | Out-File -FilePath $tmpDstFile -Force + + $modifiedContentJson = GetModifiedSettingsContent -srcSettingsFile $tmpSrcFile -dstSettingsFile $tmpDstFile + + $modifiedContent = $modifiedContentJson | ConvertFrom-Json + $modifiedContent | Should -Not -BeNullOrEmpty + $modifiedContent.PSObject.Properties.Name.Count | Should -Be 2 # setting1 and $schema + $modifiedContent."setting1" | Should -Be "value2" + $modifiedContent."`$schema" | Should -Be "someSchema" + } + + It 'GetModifiedSettingsContent returns correct content when destination file is empty' { + # Create only the source file + @{ "`$schema" = "someSchema"; "srcSetting" = "value1" } | ConvertTo-Json -Depth 10 | Out-File -FilePath $tmpSrcFile -Force + '' | Out-File -FilePath $tmpDstFile -Force + $modifiedContentJson = GetModifiedSettingsContent -srcSettingsFile $tmpSrcFile -dstSettingsFile $tmpDstFile + + $modifiedContent = $modifiedContentJson | ConvertFrom-Json + $modifiedContent | Should -Not -BeNullOrEmpty + @($modifiedContent.PSObject.Properties.Name).Count | Should -Be 2 # srcSetting and $schema + $modifiedContent."`$schema" | Should -Be "someSchema" + $modifiedContent."srcSetting" | Should -Be "value1" + } + + It 'GetModifiedSettingsContent returns correct content when destination file does not exist' { + # Create only the source file + @{ "`$schema" = "someSchema"; "srcSetting" = "value1" } | ConvertTo-Json -Depth 10 | Out-File -FilePath $tmpSrcFile -Force + + Test-Path $tmpDstFile | Should -Be $false + $modifiedContentJson = GetModifiedSettingsContent -srcSettingsFile $tmpSrcFile -dstSettingsFile $tmpDstFile + + $modifiedContent = $modifiedContentJson | ConvertFrom-Json + $modifiedContent | Should -Not -BeNullOrEmpty + $modifiedContent.PSObject.Properties.Name.Count | Should -Be 2 # srcSetting and $schema + $modifiedContent."srcSetting" | Should -Be "value1" + $modifiedContent."`$schema" | Should -Be "someSchema" + } + + It 'ApplyWorkflowDefaultInputs applies default values to workflow inputs' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with workflow_dispatch inputs + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " directCommit:", + " description: Direct Commit?", + " type: boolean", + " default: false", + " useGhTokenWorkflow:", + " description: Use GhTokenWorkflow?", + " type: boolean", + " default: false", + " updateVersionNumber:", + " description: Version number", + " required: false", + " default: ''", + "jobs:", + " test:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo test" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with workflow input defaults + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "directCommit"; "value" = $true }, + @{ "name" = "useGhTokenWorkflow"; "value" = $true }, + @{ "name" = "updateVersionNumber"; "value" = "+0.1" } + ) + } + + # Apply the defaults + ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" + + # Verify the defaults were applied + $yaml.Get('on:/workflow_dispatch:/inputs:/directCommit:/default:').content -join '' | Should -Be 'default: true' + $yaml.Get('on:/workflow_dispatch:/inputs:/useGhTokenWorkflow:/default:').content -join '' | Should -Be 'default: true' + $yaml.Get('on:/workflow_dispatch:/inputs:/updateVersionNumber:/default:').content -join '' | Should -Be "default: '+0.1'" + } + + It 'ApplyWorkflowDefaultInputs handles empty workflowDefaultInputs array' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " myInput:", + " type: boolean", + " default: false", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + $originalContent = $yaml.content -join "`n" + + # Create settings with empty workflowDefaultInputs array + $repoSettings = @{ + "workflowDefaultInputs" = @() + } + + # Apply the defaults - should not throw and should not modify workflow + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | Should -Not -Throw + $yaml.content -join "`n" | Should -Be $originalContent + $yaml.Get('on:/workflow_dispatch:/inputs:/myInput:/default:').content -join '' | Should -Be 'default: false' + } + + It 'ApplyWorkflowDefaultInputs handles workflows without workflow_dispatch' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML without workflow_dispatch + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " push:", + " branches: [ main ]", + "jobs:", + " test:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo test" + ) + + $yaml = [Yaml]::new($yamlContent) + $originalContent = $yaml.content -join "`n" + + # Create settings with workflow input defaults + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "directCommit"; "value" = $true } + ) + } + + # Apply the defaults - should not throw or modify YAML + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | Should -Not -Throw + $yaml.content -join "`n" | Should -Be $originalContent + } + + It 'ApplyWorkflowDefaultInputs handles workflow_dispatch without inputs section' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with workflow_dispatch but no inputs + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + $originalContent = $yaml.content -join "`n" + + # Create settings with workflow input defaults + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "someInput"; "value" = $true } + ) + } + + # Apply the defaults - should not throw or modify YAML + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | Should -Not -Throw + $yaml.content -join "`n" | Should -Be $originalContent + } + + It 'ApplyWorkflowDefaultInputs applies multiple defaults to same workflow' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with multiple inputs + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " input1:", + " type: boolean", + " default: false", + " input2:", + " type: number", + " default: 0", + " input3:", + " type: string", + " default: ''", + " input4:", + " type: choice", + " options:", + " - optionA", + " - optionB", + " default: optionA", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with multiple defaults + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "input1"; "value" = $true }, + @{ "name" = "input2"; "value" = 5 }, + @{ "name" = "input3"; "value" = "test-value" }, + @{ "name" = "input4"; "value" = "optionB" } + ) + } + + # Apply the defaults + ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" + + # Verify all defaults were applied + $yaml.Get('on:/workflow_dispatch:/inputs:/input1:/default:').content -join '' | Should -Be 'default: true' + $yaml.Get('on:/workflow_dispatch:/inputs:/input2:/default:').content -join '' | Should -Be 'default: 5' + $yaml.Get('on:/workflow_dispatch:/inputs:/input3:/default:').content -join '' | Should -Be "default: 'test-value'" + $yaml.Get('on:/workflow_dispatch:/inputs:/input4:/default:').content -join '' | Should -Be "default: 'optionB'" + } + + It 'ApplyWorkflowDefaultInputs inserts default line when missing' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with input without default line (only description) + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " myInput:", + " description: 'My input without default'", + " type: string", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with default value + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "myInput"; "value" = "new-default" } + ) + } + + # Apply the defaults + ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" + + # Verify default line was inserted + $defaultLine = $yaml.Get('on:/workflow_dispatch:/inputs:/myInput:/default:') + $defaultLine | Should -Not -BeNullOrEmpty + $defaultLine.content -join '' | Should -Be "default: 'new-default'" + } + + It 'ApplyWorkflowDefaultInputs is case-insensitive for input names' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with specific casing + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " MyInput:", + " type: boolean", + " default: false", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with different casing + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "myInput"; "value" = $true } + ) + } + + # Apply the defaults + ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" + + # Verify default WAS applied despite case difference (case-insensitive matching) + $yaml.Get('on:/workflow_dispatch:/inputs:/MyInput:/default:').content -join '' | Should -Be 'default: true' + } + + It 'ApplyWorkflowDefaultInputs ignores defaults for non-existent inputs' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " existingInput:", + " type: boolean", + " default: false", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + $originalContent = $yaml.content -join "`n" + + # Create settings with only non-existent input names + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "nonExistentInput"; "value" = "ignored" }, + @{ "name" = "anotherMissingInput"; "value" = 42 } + ) + } + + # Apply defaults for non-existent inputs - should not throw or modify YAML + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | Should -Not -Throw + $yaml.content -join "`n" | Should -Be $originalContent + } + + It 'ApplyWorkflowDefaultInputs applies only existing inputs when mixed with non-existent inputs' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " existingInput:", + " type: boolean", + " default: false", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with both existing and non-existent input names + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "existingInput"; "value" = $true }, + @{ "name" = "nonExistentInput"; "value" = "ignored" }, + @{ "name" = "anotherMissingInput"; "value" = 42 } + ) + } + + # Apply the defaults - should not throw + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | Should -Not -Throw + + # Verify only the existing input was modified + $yaml.Get('on:/workflow_dispatch:/inputs:/existingInput:/default:').content -join '' | Should -Be 'default: true' + } + + It 'ApplyWorkflowDefaultInputs handles special YAML characters in string values' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " input1:", + " type: string", + " default: ''", + " input2:", + " type: string", + " default: ''", + " input3:", + " type: string", + " default: ''", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with special YAML characters + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "input1"; "value" = "value: with colon" }, + @{ "name" = "input2"; "value" = "value # with comment" }, + @{ "name" = "input3"; "value" = "value with 'quotes' inside" } + ) + } + + # Apply the defaults + ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" + + # Verify values are properly quoted and escaped + $yaml.Get('on:/workflow_dispatch:/inputs:/input1:/default:').content -join '' | Should -Be "default: 'value: with colon'" + $yaml.Get('on:/workflow_dispatch:/inputs:/input2:/default:').content -join '' | Should -Be "default: 'value # with comment'" + $yaml.Get('on:/workflow_dispatch:/inputs:/input3:/default:').content -join '' | Should -Be "default: 'value with ''quotes'' inside'" + } + + It 'ApplyWorkflowDefaultInputs handles environment input type' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with environment type + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " environmentName:", + " description: Environment to deploy to", + " type: environment", + " default: ''", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with environment value (should be treated as string) + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "environmentName"; "value" = "production" } + ) + } + + # Apply the defaults + ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" + + # Verify environment value is set as string + $yaml.Get('on:/workflow_dispatch:/inputs:/environmentName:/default:').content -join '' | Should -Be "default: 'production'" + } + + It 'ApplyWorkflowDefaultInputs validates invalid choice value not in options' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with choice input + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " deploymentType:", + " type: choice", + " options:", + " - Development", + " - Staging", + " - Production", + " default: Development", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with invalid choice value + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "deploymentType"; "value" = "Testing" } + ) + } + + # Apply the defaults - should throw validation error + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | + Should -Throw "*not a valid choice*" + } + + It 'ApplyWorkflowDefaultInputs handles inputs without existing default' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with input without default + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " myInput:", + " description: My Input", + " required: false", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with workflow input defaults + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "myInput"; "value" = "test-value" } + ) + } + + # Apply the defaults + ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" + + # Verify the default was added + $defaultLine = $yaml.Get('on:/workflow_dispatch:/inputs:/myInput:/default:') + $defaultLine | Should -Not -BeNullOrEmpty + $defaultLine.content -join '' | Should -Be "default: 'test-value'" + } + + It 'ApplyWorkflowDefaultInputs handles different value types' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " boolInput:", + " type: boolean", + " default: false", + " stringInput:", + " type: string", + " default: ''", + " numberInput:", + " type: number", + " default: 0", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with different value types + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "boolInput"; "value" = $true }, + @{ "name" = "stringInput"; "value" = "test" }, + @{ "name" = "numberInput"; "value" = 42 } + ) + } + + # Apply the defaults + ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" + + # Verify the defaults were applied with correct types + $yaml.Get('on:/workflow_dispatch:/inputs:/boolInput:/default:').content -join '' | Should -Be 'default: true' + $yaml.Get('on:/workflow_dispatch:/inputs:/stringInput:/default:').content -join '' | Should -Be "default: 'test'" + $yaml.Get('on:/workflow_dispatch:/inputs:/numberInput:/default:').content -join '' | Should -Be 'default: 42' + } + + It 'ApplyWorkflowDefaultInputs validates boolean type mismatch' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with boolean input + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " boolInput:", + " type: boolean", + " default: false", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with wrong type (string instead of boolean) + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "boolInput"; "value" = "not a boolean" } + ) + } + + # Apply the defaults - should throw validation error + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | + Should -Throw "*Expected boolean value*" + } + + It 'ApplyWorkflowDefaultInputs validates number type mismatch' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with number input + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " numberInput:", + " type: number", + " default: 0", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with wrong type (string instead of number) + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "numberInput"; "value" = "not a number" } + ) + } + + # Apply the defaults - should throw validation error + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | + Should -Throw "*Expected number value*" + } + + It 'ApplyWorkflowDefaultInputs validates string type mismatch' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with string input + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " stringInput:", + " type: string", + " default: ''", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with wrong type (boolean instead of string) + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "stringInput"; "value" = $true } + ) + } + + # Apply the defaults - should throw validation error + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | + Should -Throw "*Expected string value*" + } + + It 'ApplyWorkflowDefaultInputs validates choice type' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with choice input + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " choiceInput:", + " type: choice", + " options:", + " - option1", + " - option2", + " default: option1", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with correct type (string for choice) + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "choiceInput"; "value" = "option2" } + ) + } + + # Apply the defaults - should succeed + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | Should -Not -Throw + $yaml.Get('on:/workflow_dispatch:/inputs:/choiceInput:/default:').content -join '' | Should -Be "default: 'option2'" + } + + It 'ApplyWorkflowDefaultInputs validates choice value is in available options' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with choice input + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " choiceInput:", + " type: choice", + " options:", + " - option1", + " - option2", + " - option3", + " default: option1", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with invalid choice value + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "choiceInput"; "value" = "invalidOption" } + ) + } + + # Apply the defaults - should throw validation error + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | + Should -Throw "*not a valid choice*" + } + + It 'ApplyWorkflowDefaultInputs validates choice value with case-sensitive matching' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with choice input using mixed case options + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " releaseTypeInput:", + " type: choice", + " options:", + " - Release", + " - Prerelease", + " - Draft", + " default: Release", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Test 1: Exact case match should succeed + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "releaseTypeInput"; "value" = "Prerelease" } + ) + } + + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | Should -Not -Throw + $yaml.Get('on:/workflow_dispatch:/inputs:/releaseTypeInput:/default:').content -join '' | Should -Be "default: 'Prerelease'" + + # Test 2: Wrong case should fail with case-sensitive error message + $yaml2 = [Yaml]::new($yamlContent) + $repoSettings2 = @{ + "workflowDefaultInputs" = @( + @{ "name" = "releaseTypeInput"; "value" = "prerelease" } + ) + } + + { 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) + $repoSettings3 = @{ + "workflowDefaultInputs" = @( + @{ "name" = "releaseTypeInput"; "value" = "PRERELEASE" } + ) + } + + { ApplyWorkflowDefaultInputs -yaml $yaml3 -repoSettings $repoSettings3 -workflowName "Test Workflow" } | + Should -Throw "*case-sensitive match required*" + } + + It 'ApplyWorkflowDefaultInputs handles inputs without type specification' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML without type (defaults to string) + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " noTypeInput:", + " description: Input without type", + " default: ''", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with string value (should work without warning) + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "noTypeInput"; "value" = "string value" } + ) + } + + # Apply the defaults - should succeed + { ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" } | Should -Not -Throw + $yaml.Get('on:/workflow_dispatch:/inputs:/noTypeInput:/default:').content -join '' | Should -Be "default: 'string value'" + } + + It 'ApplyWorkflowDefaultInputs escapes single quotes in string values' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML with string input + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " nameInput:", + " type: string", + " default: ''", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with string value containing single quote + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "nameInput"; "value" = "O'Brien" } + ) + } + + # Apply the defaults + ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" + + # Verify single quote is escaped per YAML spec (doubled) + $yaml.Get('on:/workflow_dispatch:/inputs:/nameInput:/default:').content -join '' | Should -Be "default: 'O''Brien'" + } + + It 'ApplyWorkflowDefaultInputs applies last value when multiple entries have same input name' { + . (Join-Path $scriptRoot "yamlclass.ps1") + + # Create a test workflow YAML + $yamlContent = @( + "name: 'Test Workflow'", + "on:", + " workflow_dispatch:", + " inputs:", + " input1:", + " type: string", + " default: ''", + " input2:", + " type: boolean", + " default: false", + "jobs:", + " test:", + " runs-on: ubuntu-latest" + ) + + $yaml = [Yaml]::new($yamlContent) + + # Create settings with duplicate entries for input1 - simulating merged conditional settings + # This can happen when multiple conditionalSettings blocks both match and both define the same input + $repoSettings = @{ + "workflowDefaultInputs" = @( + @{ "name" = "input1"; "value" = "first-value" }, + @{ "name" = "input2"; "value" = $false }, + @{ "name" = "input1"; "value" = "second-value" }, # Duplicate input1 + @{ "name" = "input1"; "value" = "final-value" } # Another duplicate input1 + ) + } + + # Apply the defaults + ApplyWorkflowDefaultInputs -yaml $yaml -repoSettings $repoSettings -workflowName "Test Workflow" + + # Verify "last wins" - the final value for input1 should be applied + $yaml.Get('on:/workflow_dispatch:/inputs:/input1:/default:').content -join '' | Should -Be "default: 'final-value'" + $yaml.Get('on:/workflow_dispatch:/inputs:/input2:/default:').content -join '' | Should -Be 'default: false' + } +} diff --git a/Tests/ReadSettings.Test.ps1 b/Tests/ReadSettings.Test.ps1 index 18dc52beb..8443a5fb1 100644 --- a/Tests/ReadSettings.Test.ps1 +++ b/Tests/ReadSettings.Test.ps1 @@ -459,5 +459,65 @@ InModuleScope ReadSettings { # Allows testing of private functions # overwriteSettings should never be added to the destination object $dst.PSObject.Properties.Name | Should -Not -Contain 'overwriteSettings' } + + It 'Multiple conditionalSettings with same array setting are merged (all entries kept)' { + Mock Write-Host { } + Mock Out-Host { } + + Push-Location + $tempName = Join-Path ([System.IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString()) + $githubFolder = Join-Path $tempName ".github" + New-Item $githubFolder -ItemType Directory | Out-Null + + # Create conditional settings with two blocks that both match and both have workflowDefaultInputs + $conditionalSettings = [ordered]@{ + "conditionalSettings" = @( + @{ + "branches" = @( 'main' ) + "settings" = @{ + "workflowDefaultInputs" = @( + @{ "name" = "input1"; "value" = "value1" } + ) + } + } + @{ + "branches" = @( 'main' ) + "settings" = @{ + "workflowDefaultInputs" = @( + @{ "name" = "input1"; "value" = "value2" }, + @{ "name" = "input2"; "value" = "value3" } + ) + } + } + ) + } + $ENV:ALGoOrgSettings = '' + $ENV:ALGoRepoSettings = $conditionalSettings | ConvertTo-Json -Depth 99 + + # Both conditional blocks match branch 'main', so both should be applied + $settings = ReadSettings -baseFolder $tempName -project '' -repoName 'repo' -workflowName 'Workflow' -branchName 'main' -userName 'user' + + # Verify array was merged - should have 3 entries total + $settings.workflowDefaultInputs | Should -Not -BeNullOrEmpty + $settings.workflowDefaultInputs.Count | Should -Be 3 + + # First entry from first conditional block + $settings.workflowDefaultInputs[0].name | Should -Be 'input1' + $settings.workflowDefaultInputs[0].value | Should -Be 'value1' + + # Second entry from second conditional block + $settings.workflowDefaultInputs[1].name | Should -Be 'input1' + $settings.workflowDefaultInputs[1].value | Should -Be 'value2' + + # Third entry from second conditional block + $settings.workflowDefaultInputs[2].name | Should -Be 'input2' + $settings.workflowDefaultInputs[2].value | Should -Be 'value3' + + $ENV:ALGoRepoSettings = '' + + # Clean up + Pop-Location + Remove-Item -Path $tempName -Recurse -Force + } } }