diff --git a/.github/scripts/Expose-TestData.ps1 b/.github/scripts/Expose-TestData.ps1 new file mode 100644 index 00000000..6897c2a3 --- /dev/null +++ b/.github/scripts/Expose-TestData.ps1 @@ -0,0 +1,138 @@ +if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_DATA)) { + Write-Output 'No test data was provided by the calling workflow.' + return +} +try { + $data = $env:PSMODULE_TEST_DATA | ConvertFrom-Json -ErrorAction Stop +} catch { + throw "The 'TestData' secret must be valid JSON with 'secrets' and/or 'variables' maps." +} +if ($null -eq $data -or $data -isnot [pscustomobject]) { + throw "The 'TestData' secret must be a JSON object with 'secrets' and/or 'variables' maps." +} +$allowedTopLevelKeys = @('secrets', 'variables') +foreach ($propertyName in $data.PSObject.Properties.Name) { + if ($allowedTopLevelKeys -notcontains $propertyName) { + throw "The 'TestData' secret only supports 'secrets' and 'variables' maps." + } +} +$reservedNames = @('CI', 'HOME', 'PATH', 'PWD', 'SHELL', 'PSMODULE_TEST_DATA') +$reservedPrefixes = @('GITHUB_', 'RUNNER_', 'ACTIONS_') +function Assert-EnvironmentName { + <# + .SYNOPSIS + Validates that a TestData key can safely be written to GITHUB_ENV. + #> + param([string] $Name) + if ($Name -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') { + throw 'TestData keys must be valid environment variable names.' + } + $normalized = $Name.ToUpperInvariant() + if ($reservedNames -contains $normalized) { + throw 'TestData keys must not override reserved environment variables.' + } + foreach ($prefix in $reservedPrefixes) { + if ($normalized.StartsWith($prefix)) { + throw 'TestData keys must not override reserved environment variables.' + } + } +} +function Assert-Map { + <# + .SYNOPSIS + Validates that a TestData section is a JSON object map. + #> + param( + [object] $Map, + [string] $Name + ) + if ($null -eq $Map) { return } + if ($Map -isnot [pscustomobject]) { + throw "The 'TestData.$Name' value must be a JSON object." + } +} +function Get-EnvironmentValue { + <# + .SYNOPSIS + Converts a scalar TestData value to an environment variable value. + #> + param( + [object] $Value, + [string] $Name + ) + if ($null -eq $Value) { return '' } + if ( + $Value -is [pscustomobject] -or + ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) + ) { + throw "Values in 'TestData.$Name' must be scalar values." + } + return [string]$Value +} +function Add-EnvFromMap { + <# + .SYNOPSIS + Writes validated TestData entries to GITHUB_ENV. + #> + param( + [object] $Map, + [string] $Name, + [switch] $Mask + ) + Assert-Map -Map $Map -Name $Name + if ($null -eq $Map) { return } + $count = 0 + foreach ($item in $Map.PSObject.Properties) { + $name = $item.Name + Assert-EnvironmentName -Name $name + $value = Get-EnvironmentValue -Value $item.Value -Name $Name + if ($Mask) { + foreach ($line in ($value -split "`n")) { + $line = $line.TrimEnd("`r") + if ($line.Length -gt 0) { + Write-Output "::add-mask::$line" + } + } + } + do { + $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + } while ($value.Contains($delimiter)) + Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" -Encoding utf8 + Add-Content -Path $env:GITHUB_ENV -Value $value -Encoding utf8 + Add-Content -Path $env:GITHUB_ENV -Value $delimiter -Encoding utf8 + $count++ + } + if ($count -gt 0) { + if ($Mask) { + Write-Output "Exposed $count secret value(s) as environment variables." + } else { + Write-Output "Exposed $count variable value(s) as environment variables." + } + } +} + +Assert-Map -Map $data.secrets -Name 'secrets' +Assert-Map -Map $data.variables -Name 'variables' + +$secretNames = @() +if ($null -ne $data.secrets) { + $secretNames = @($data.secrets.PSObject.Properties.Name) +} +$variableNames = @() +if ($null -ne $data.variables) { + $variableNames = @($data.variables.PSObject.Properties.Name) +} +$secretNameSet = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +foreach ($secretName in $secretNames) { + [void] $secretNameSet.Add($secretName) +} +foreach ($variableName in $variableNames) { + if ($secretNameSet.Contains($variableName)) { + throw 'TestData keys must not be duplicated across secrets and variables.' + } +} + +Add-EnvFromMap -Map $data.secrets -Name 'secrets' -Mask +Add-EnvFromMap -Map $data.variables -Name 'variables' diff --git a/.github/workflows/AfterAll-ModuleLocal.yml b/.github/workflows/AfterAll-ModuleLocal.yml index 2fba195f..fd3346ae 100644 --- a/.github/workflows/AfterAll-ModuleLocal.yml +++ b/.github/workflows/AfterAll-ModuleLocal.yml @@ -3,26 +3,11 @@ name: AfterAll-ModuleLocal on: workflow_call: secrets: - TEST_APP_ENT_CLIENT_ID: - description: The client ID of an Enterprise GitHub App for running tests. - required: false - TEST_APP_ENT_PRIVATE_KEY: - description: The private key of an Enterprise GitHub App for running tests. - required: false - TEST_APP_ORG_CLIENT_ID: - description: The client ID of an Organization GitHub App for running tests. - required: false - TEST_APP_ORG_PRIVATE_KEY: - description: The private key of an Organization GitHub App for running tests. - required: false - TEST_USER_ORG_FG_PAT: - description: The fine-grained personal access token with org access for running tests. - required: false - TEST_USER_USER_FG_PAT: - description: The fine-grained personal access token with user account access for running tests. - required: false - TEST_USER_PAT: - description: The classic personal access token for running tests. + TestData: + description: | + Optional single-line JSON object with 'secrets' and 'variables' maps. Each entry is exposed + as an environment variable available to the AfterAll teardown script; 'secrets' values are + masked in the logs, 'variables' values are not. required: false inputs: Settings: @@ -30,15 +15,6 @@ on: description: The complete settings object including test suites. required: true -env: - TEST_APP_ENT_CLIENT_ID: ${{ secrets.TEST_APP_ENT_CLIENT_ID }} - TEST_APP_ENT_PRIVATE_KEY: ${{ secrets.TEST_APP_ENT_PRIVATE_KEY }} - TEST_APP_ORG_CLIENT_ID: ${{ secrets.TEST_APP_ORG_CLIENT_ID }} - TEST_APP_ORG_PRIVATE_KEY: ${{ secrets.TEST_APP_ORG_PRIVATE_KEY }} - TEST_USER_ORG_FG_PAT: ${{ secrets.TEST_USER_ORG_FG_PAT }} - TEST_USER_USER_FG_PAT: ${{ secrets.TEST_USER_USER_FG_PAT }} - TEST_USER_PAT: ${{ secrets.TEST_USER_PAT }} - permissions: contents: read # to checkout the repo @@ -55,6 +31,13 @@ jobs: persist-credentials: false fetch-depth: 0 + - name: Expose caller-provided test data + shell: pwsh + env: + PSMODULE_TEST_DATA: ${{ secrets.TestData }} + run: | + ./.github/scripts/Expose-TestData.ps1 + - name: Run AfterAll Teardown Scripts if: always() uses: PSModule/GitHub-Script@1ee97bbc652d19c38ae12f6e1e47e9d9fbd12d0a # v1.8.0 diff --git a/.github/workflows/BeforeAll-ModuleLocal.yml b/.github/workflows/BeforeAll-ModuleLocal.yml index 841096df..8b933d98 100644 --- a/.github/workflows/BeforeAll-ModuleLocal.yml +++ b/.github/workflows/BeforeAll-ModuleLocal.yml @@ -3,26 +3,11 @@ name: BeforeAll-ModuleLocal on: workflow_call: secrets: - TEST_APP_ENT_CLIENT_ID: - description: The client ID of an Enterprise GitHub App for running tests. - required: false - TEST_APP_ENT_PRIVATE_KEY: - description: The private key of an Enterprise GitHub App for running tests. - required: false - TEST_APP_ORG_CLIENT_ID: - description: The client ID of an Organization GitHub App for running tests. - required: false - TEST_APP_ORG_PRIVATE_KEY: - description: The private key of an Organization GitHub App for running tests. - required: false - TEST_USER_ORG_FG_PAT: - description: The fine-grained personal access token with org access for running tests. - required: false - TEST_USER_USER_FG_PAT: - description: The fine-grained personal access token with user account access for running tests. - required: false - TEST_USER_PAT: - description: The classic personal access token for running tests. + TestData: + description: | + Optional single-line JSON object with 'secrets' and 'variables' maps. Each entry is exposed + as an environment variable available to the BeforeAll setup script; 'secrets' values are + masked in the logs, 'variables' values are not. required: false inputs: Settings: @@ -30,15 +15,6 @@ on: description: The complete settings object including test suites. required: true -env: - TEST_APP_ENT_CLIENT_ID: ${{ secrets.TEST_APP_ENT_CLIENT_ID }} - TEST_APP_ENT_PRIVATE_KEY: ${{ secrets.TEST_APP_ENT_PRIVATE_KEY }} - TEST_APP_ORG_CLIENT_ID: ${{ secrets.TEST_APP_ORG_CLIENT_ID }} - TEST_APP_ORG_PRIVATE_KEY: ${{ secrets.TEST_APP_ORG_PRIVATE_KEY }} - TEST_USER_ORG_FG_PAT: ${{ secrets.TEST_USER_ORG_FG_PAT }} - TEST_USER_USER_FG_PAT: ${{ secrets.TEST_USER_USER_FG_PAT }} - TEST_USER_PAT: ${{ secrets.TEST_USER_PAT }} - permissions: contents: read # to checkout the repo @@ -55,6 +31,13 @@ jobs: persist-credentials: false fetch-depth: 0 + - name: Expose caller-provided test data + shell: pwsh + env: + PSMODULE_TEST_DATA: ${{ secrets.TestData }} + run: | + ./.github/scripts/Expose-TestData.ps1 + - name: Run BeforeAll Setup Scripts uses: PSModule/GitHub-Script@1ee97bbc652d19c38ae12f6e1e47e9d9fbd12d0a # v1.8.0 with: diff --git a/.github/workflows/Test-Module.yml b/.github/workflows/Test-Module.yml index d611cdd7..d5320a2e 100644 --- a/.github/workflows/Test-Module.yml +++ b/.github/workflows/Test-Module.yml @@ -2,43 +2,12 @@ name: Test-Module on: workflow_call: - secrets: - TEST_APP_ENT_CLIENT_ID: - description: The client ID of an Enterprise GitHub App for running tests. - required: false - TEST_APP_ENT_PRIVATE_KEY: - description: The private key of an Enterprise GitHub App for running tests. - required: false - TEST_APP_ORG_CLIENT_ID: - description: The client ID of an Organization GitHub App for running tests. - required: false - TEST_APP_ORG_PRIVATE_KEY: - description: The private key of an Organization GitHub App for running tests. - required: false - TEST_USER_ORG_FG_PAT: - description: The fine-grained personal access token with org access for running tests. - required: false - TEST_USER_USER_FG_PAT: - description: The fine-grained personal access token with user account access for running tests. - required: false - TEST_USER_PAT: - description: The classic personal access token for running tests. - required: false inputs: Settings: type: string description: The settings object as a JSON string. required: true -env: - TEST_APP_ENT_CLIENT_ID: ${{ secrets.TEST_APP_ENT_CLIENT_ID }} - TEST_APP_ENT_PRIVATE_KEY: ${{ secrets.TEST_APP_ENT_PRIVATE_KEY }} - TEST_APP_ORG_CLIENT_ID: ${{ secrets.TEST_APP_ORG_CLIENT_ID }} - TEST_APP_ORG_PRIVATE_KEY: ${{ secrets.TEST_APP_ORG_PRIVATE_KEY }} - TEST_USER_ORG_FG_PAT: ${{ secrets.TEST_USER_ORG_FG_PAT }} - TEST_USER_USER_FG_PAT: ${{ secrets.TEST_USER_USER_FG_PAT }} - TEST_USER_PAT: ${{ secrets.TEST_USER_PAT }} - permissions: contents: read # to checkout the repo and create releases on the repo diff --git a/.github/workflows/Test-ModuleLocal.yml b/.github/workflows/Test-ModuleLocal.yml index 8bb87dcd..c8e806a2 100644 --- a/.github/workflows/Test-ModuleLocal.yml +++ b/.github/workflows/Test-ModuleLocal.yml @@ -3,26 +3,11 @@ name: Test-ModuleLocal on: workflow_call: secrets: - TEST_APP_ENT_CLIENT_ID: - description: The client ID of an Enterprise GitHub App for running tests. - required: false - TEST_APP_ENT_PRIVATE_KEY: - description: The private key of an Enterprise GitHub App for running tests. - required: false - TEST_APP_ORG_CLIENT_ID: - description: The client ID of an Organization GitHub App for running tests. - required: false - TEST_APP_ORG_PRIVATE_KEY: - description: The private key of an Organization GitHub App for running tests. - required: false - TEST_USER_ORG_FG_PAT: - description: The fine-grained personal access token with org access for running tests. - required: false - TEST_USER_USER_FG_PAT: - description: The fine-grained personal access token with user account access for running tests. - required: false - TEST_USER_PAT: - description: The classic personal access token for running tests. + TestData: + description: | + Optional single-line JSON object with 'secrets' and 'variables' maps. Each entry is exposed + as an environment variable the module's Pester tests read via $env:; 'secrets' values + are masked in the logs, 'variables' values are not. required: false inputs: Settings: @@ -34,13 +19,6 @@ permissions: contents: read # to checkout the repo and create releases on the repo env: - TEST_APP_ENT_CLIENT_ID: ${{ secrets.TEST_APP_ENT_CLIENT_ID }} - TEST_APP_ENT_PRIVATE_KEY: ${{ secrets.TEST_APP_ENT_PRIVATE_KEY }} - TEST_APP_ORG_CLIENT_ID: ${{ secrets.TEST_APP_ORG_CLIENT_ID }} - TEST_APP_ORG_PRIVATE_KEY: ${{ secrets.TEST_APP_ORG_PRIVATE_KEY }} - TEST_USER_ORG_FG_PAT: ${{ secrets.TEST_USER_ORG_FG_PAT }} - TEST_USER_USER_FG_PAT: ${{ secrets.TEST_USER_USER_FG_PAT }} - TEST_USER_PAT: ${{ secrets.TEST_USER_PAT }} GITHUB_TOKEN: ${{ github.token }} jobs: @@ -58,6 +36,13 @@ jobs: persist-credentials: false fetch-depth: 0 + - name: Expose caller-provided test data + shell: pwsh + env: + PSMODULE_TEST_DATA: ${{ secrets.TestData }} + run: | + ./.github/scripts/Expose-TestData.ps1 + - name: Download module artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index 872c37ec..f96c41b3 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -25,16 +25,21 @@ permissions: jobs: WorkflowTestDefault: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} uses: ./.github/workflows/workflow.yml secrets: APIKey: ${{ secrets.APIKey }} - TEST_APP_ENT_CLIENT_ID: ${{ secrets.TEST_APP_ENT_CLIENT_ID }} - TEST_APP_ENT_PRIVATE_KEY: ${{ secrets.TEST_APP_ENT_PRIVATE_KEY }} - TEST_APP_ORG_CLIENT_ID: ${{ secrets.TEST_APP_ORG_CLIENT_ID }} - TEST_APP_ORG_PRIVATE_KEY: ${{ secrets.TEST_APP_ORG_PRIVATE_KEY }} - TEST_USER_ORG_FG_PAT: ${{ secrets.TEST_USER_ORG_FG_PAT }} - TEST_USER_USER_FG_PAT: ${{ secrets.TEST_USER_USER_FG_PAT }} - TEST_USER_PAT: ${{ secrets.TEST_USER_PAT }} + # Self-test only: a dedicated, NON-SENSITIVE repository secret + variable exist purely to prove + # the TestData plumbing end to end - the "secrets" entry is masked and the "variables" entry is + # not, and both are exposed as $env:. Their known values are asserted (value + length) in + # tests/.../Environment.Tests.ps1. + # Secrets use the direct "${{ secrets.X }}" form (CodeQL-clean; avoids toJSON(secrets.*)), which + # requires single-line secret values with no embedded quotes or backslashes; variables use + # toJSON(vars.X) so any characters are encoded safely. The folded '>-' scalar keeps the whole blob + # on ONE line so GitHub registers a single mask instead of one per line. + TestData: >- + { "secrets": { "PSMODULE_TEST_SINGLELINE_SECRET": "${{ secrets.PSMODULE_TEST_SINGLELINE_SECRET }}" }, + "variables": { "PSMODULE_TEST_VARIABLE": ${{ toJSON(vars.PSMODULE_TEST_VARIABLE) }} } } with: WorkingDirectory: tests/srcTestRepo ImportantFilePatterns: | diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 2e42981c..b7242098 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -25,16 +25,21 @@ permissions: jobs: WorkflowTestWithManifest: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} uses: ./.github/workflows/workflow.yml secrets: APIKey: ${{ secrets.APIKey }} - TEST_APP_ENT_CLIENT_ID: ${{ secrets.TEST_APP_ENT_CLIENT_ID }} - TEST_APP_ENT_PRIVATE_KEY: ${{ secrets.TEST_APP_ENT_PRIVATE_KEY }} - TEST_APP_ORG_CLIENT_ID: ${{ secrets.TEST_APP_ORG_CLIENT_ID }} - TEST_APP_ORG_PRIVATE_KEY: ${{ secrets.TEST_APP_ORG_PRIVATE_KEY }} - TEST_USER_ORG_FG_PAT: ${{ secrets.TEST_USER_ORG_FG_PAT }} - TEST_USER_USER_FG_PAT: ${{ secrets.TEST_USER_USER_FG_PAT }} - TEST_USER_PAT: ${{ secrets.TEST_USER_PAT }} + # Self-test only: a dedicated, NON-SENSITIVE repository secret + variable exist purely to prove + # the TestData plumbing end to end - the "secrets" entry is masked and the "variables" entry is + # not, and both are exposed as $env:. Their known values are asserted (value + length) in + # tests/.../Environment.Tests.ps1. + # Secrets use the direct "${{ secrets.X }}" form (CodeQL-clean; avoids toJSON(secrets.*)), which + # requires single-line secret values with no embedded quotes or backslashes; variables use + # toJSON(vars.X) so any characters are encoded safely. The folded '>-' scalar keeps the whole blob + # on ONE line so GitHub registers a single mask instead of one per line. + TestData: >- + { "secrets": { "PSMODULE_TEST_SINGLELINE_SECRET": "${{ secrets.PSMODULE_TEST_SINGLELINE_SECRET }}" }, + "variables": { "PSMODULE_TEST_VARIABLE": ${{ toJSON(vars.PSMODULE_TEST_VARIABLE) }} } } with: WorkingDirectory: tests/srcWithManifestTestRepo ImportantFilePatterns: | diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 9a4bc6af..621e49f1 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -6,26 +6,16 @@ on: APIKey: description: The API key for the PowerShell Gallery. required: true - TEST_APP_ENT_CLIENT_ID: - description: The client ID of an Enterprise GitHub App for running tests. - required: false - TEST_APP_ENT_PRIVATE_KEY: - description: The private key of an Enterprise GitHub App for running tests. - required: false - TEST_APP_ORG_CLIENT_ID: - description: The client ID of an Organization GitHub App for running tests. - required: false - TEST_APP_ORG_PRIVATE_KEY: - description: The private key of an Organization GitHub App for running tests. - required: false - TEST_USER_ORG_FG_PAT: - description: The fine-grained personal access token with org access for running tests. - required: false - TEST_USER_USER_FG_PAT: - description: The fine-grained personal access token with user account access for running tests. - required: false - TEST_USER_PAT: - description: The classic personal access token for running tests. + TestData: + description: | + Optional single-line JSON object carrying all data the module test jobs + (BeforeAll-ModuleLocal, Test-ModuleLocal, AfterAll-ModuleLocal) need, in two maps: + { "secrets": { "NAME": "value", ... }, "variables": { "NAME": "value", ... } } + Every entry is exposed as an environment variable the module's Pester tests read via + $env:. Values under "secrets" are masked in the logs; values under "variables" are not. + Build it from secrets. / vars. in the calling workflow (see the README). Keep it + on a single line so GitHub registers one mask for the whole value. Omit it when the module + needs no test data. required: false inputs: SettingsPath: @@ -70,11 +60,11 @@ on: ^README\.md$ permissions: - contents: write # to checkout the repo and create releases on the repo + contents: write # to checkout the repo and create releases on the repo pull-requests: write # to write comments to PRs - statuses: write # to update the status of the workflow from linter - pages: write # to deploy to Pages - id-token: write # to verify the deployment originates from an appropriate source + statuses: write # to update the status of the workflow from linter + pages: write # to deploy to Pages + id-token: write # to verify the deployment originates from an appropriate source jobs: # Runs on: @@ -168,13 +158,7 @@ jobs: if: fromJson(needs.Get-Settings.outputs.Settings).Run.BeforeAllModuleLocal && needs.Build-Module.result == 'success' && !cancelled() uses: ./.github/workflows/BeforeAll-ModuleLocal.yml secrets: - TEST_APP_ENT_CLIENT_ID: ${{ secrets.TEST_APP_ENT_CLIENT_ID }} - TEST_APP_ENT_PRIVATE_KEY: ${{ secrets.TEST_APP_ENT_PRIVATE_KEY }} - TEST_APP_ORG_CLIENT_ID: ${{ secrets.TEST_APP_ORG_CLIENT_ID }} - TEST_APP_ORG_PRIVATE_KEY: ${{ secrets.TEST_APP_ORG_PRIVATE_KEY }} - TEST_USER_ORG_FG_PAT: ${{ secrets.TEST_USER_ORG_FG_PAT }} - TEST_USER_USER_FG_PAT: ${{ secrets.TEST_USER_USER_FG_PAT }} - TEST_USER_PAT: ${{ secrets.TEST_USER_PAT }} + TestData: ${{ secrets.TestData }} needs: - Build-Module - Get-Settings @@ -194,13 +178,7 @@ jobs: - BeforeAll-ModuleLocal uses: ./.github/workflows/Test-ModuleLocal.yml secrets: - TEST_APP_ENT_CLIENT_ID: ${{ secrets.TEST_APP_ENT_CLIENT_ID }} - TEST_APP_ENT_PRIVATE_KEY: ${{ secrets.TEST_APP_ENT_PRIVATE_KEY }} - TEST_APP_ORG_CLIENT_ID: ${{ secrets.TEST_APP_ORG_CLIENT_ID }} - TEST_APP_ORG_PRIVATE_KEY: ${{ secrets.TEST_APP_ORG_PRIVATE_KEY }} - TEST_USER_ORG_FG_PAT: ${{ secrets.TEST_USER_ORG_FG_PAT }} - TEST_USER_USER_FG_PAT: ${{ secrets.TEST_USER_USER_FG_PAT }} - TEST_USER_PAT: ${{ secrets.TEST_USER_PAT }} + TestData: ${{ secrets.TestData }} with: Settings: ${{ needs.Get-Settings.outputs.Settings }} @@ -213,13 +191,7 @@ jobs: if: fromJson(needs.Get-Settings.outputs.Settings).Run.AfterAllModuleLocal && needs.Test-ModuleLocal.result != 'skipped' && always() uses: ./.github/workflows/AfterAll-ModuleLocal.yml secrets: - TEST_APP_ENT_CLIENT_ID: ${{ secrets.TEST_APP_ENT_CLIENT_ID }} - TEST_APP_ENT_PRIVATE_KEY: ${{ secrets.TEST_APP_ENT_PRIVATE_KEY }} - TEST_APP_ORG_CLIENT_ID: ${{ secrets.TEST_APP_ORG_CLIENT_ID }} - TEST_APP_ORG_PRIVATE_KEY: ${{ secrets.TEST_APP_ORG_PRIVATE_KEY }} - TEST_USER_ORG_FG_PAT: ${{ secrets.TEST_USER_ORG_FG_PAT }} - TEST_USER_USER_FG_PAT: ${{ secrets.TEST_USER_USER_FG_PAT }} - TEST_USER_PAT: ${{ secrets.TEST_USER_PAT }} + TestData: ${{ secrets.TestData }} needs: - Get-Settings - Test-ModuleLocal diff --git a/README.md b/README.md index 5a82eee7..d15b661b 100644 --- a/README.md +++ b/README.md @@ -378,7 +378,7 @@ jobs: Process-PSModule: uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v5 secrets: - APIKEY: ${{ secrets.APIKEY }} + APIKey: ${{ secrets.APIKey }} ``` @@ -397,18 +397,108 @@ jobs: ### Secrets -The following secrets are used by the workflow. They can be automatically provided (if available) by setting `secrets: inherit` in the workflow file. - -| Name | Location | Description | Default | -| ---- | -------------- | ------------------------------------------------------------------------- | ------- | -| `APIKEY` | GitHub secrets | The API key for the PowerShell Gallery. | N/A | -| `TEST_APP_ENT_CLIENT_ID` | GitHub secrets | The client ID of an Enterprise GitHub App for running tests. | N/A | -| `TEST_APP_ENT_PRIVATE_KEY` | GitHub secrets | The private key of an Enterprise GitHub App for running tests. | N/A | -| `TEST_APP_ORG_CLIENT_ID` | GitHub secrets | The client ID of an Organization GitHub App for running tests. | N/A | -| `TEST_APP_ORG_PRIVATE_KEY` | GitHub secrets | The private key of an Organization GitHub App for running tests. | N/A | -| `TEST_USER_ORG_FG_PAT` | GitHub secrets | The fine-grained PAT with organization access for running tests. | N/A | -| `TEST_USER_USER_FG_PAT` | GitHub secrets | The fine-grained PAT with user account access for running tests. | N/A | -| `TEST_USER_PAT` | GitHub secrets | The classic personal access token for running tests. | N/A | +The reusable workflow at `.github/workflows/workflow.yml` declares only two workflow-call secrets, +which keeps the calling workflow in full control of the credentials that are exposed. +`secrets: inherit` is intentionally not required. + +| Name | Location | Description | Required | +| ---- | -------- | ----------- | -------- | +| `APIKey` | GitHub secrets | The API key for the PowerShell Gallery, used to publish the module. | Yes | +| `TestData` | GitHub secrets | A single-line JSON object with `secrets` and `variables` maps, exposed as environment variables to the module test jobs. Values under `secrets` are masked; values under `variables` are not. | No | + +#### Breaking change: fixed test secrets moved to `TestData` + +The reusable workflow no longer declares or accepts the old fixed test-secret inputs: + +- `TEST_APP_ENT_CLIENT_ID` +- `TEST_APP_ENT_PRIVATE_KEY` +- `TEST_APP_ORG_CLIENT_ID` +- `TEST_APP_ORG_PRIVATE_KEY` +- `TEST_USER_ORG_FG_PAT` +- `TEST_USER_USER_FG_PAT` +- `TEST_USER_PAT` + +If a caller passed any of these secrets directly, move them into the `secrets` map inside `TestData`. +The environment variable names used by the tests can stay the same; only the workflow-call interface +changes: + +```yaml +jobs: + Process-PSModule: + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v5 + secrets: + APIKey: ${{ secrets.APIKey }} + TestData: >- + { "secrets": { "TEST_USER_PAT": "${{ secrets.TEST_USER_PAT }}", + "TEST_APP_ORG_CLIENT_ID": "${{ secrets.TEST_APP_ORG_CLIENT_ID }}" } } +``` + +#### Passing test data (secrets and variables) to the tests + +A single `TestData` secret lets a module expose any number of caller-defined values to its test jobs +(`BeforeAll-ModuleLocal`, `Test-ModuleLocal` and `AfterAll-ModuleLocal`) without changing the shared +workflow. It is one JSON object with two maps, so everything the tests need is visible in one place: + +```json +{ "secrets": { "NAME": "value" }, "variables": { "NAME": "value" } } +``` + +Values under `secrets` are masked in the logs; values under `variables` are not. Build it in the +calling workflow and pass it through the `secrets:` block (so the whole blob is masked). Reference each +secret directly as `"${{ secrets. }}"` and each variable as `${{ toJSON(vars.) }}`. A +folded `>-` scalar keeps the source readable while producing a single-line value, as long as the JSON +content lines stay at the same indentation level: + +```yaml +jobs: + Process-PSModule: + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v5 + secrets: + APIKey: ${{ secrets.APIKey }} + TestData: >- + { "secrets": { "CONFLUENCE_API_TOKEN": "${{ secrets.CONFLUENCE_API_TOKEN }}" }, + "variables": { "CONFLUENCE_SITE": ${{ toJSON(vars.CONFLUENCE_SITE) }}, + "CONFLUENCE_USERNAME": ${{ toJSON(vars.CONFLUENCE_USERNAME) }}, + "CONFLUENCE_SPACE_KEY": ${{ toJSON(vars.CONFLUENCE_SPACE_KEY) }} } } +``` + +Each entry becomes an environment variable in the test jobs, so the module's Pester tests read the +values directly: + +```powershell +$env:CONFLUENCE_API_TOKEN # from the "secrets" map (masked in logs) +$env:CONFLUENCE_SITE # from the "variables" map (not masked) +``` + +Notes: + +- The names are caller-defined; no secret or variable names are hard-coded in the shared workflow. + Names must match `^[A-Za-z_][A-Za-z0-9_]*$` and must not override reserved variables such as `PATH`, + `CI`, `GITHUB_*`, `RUNNER_*` or `ACTIONS_*`. +- The `TestData` validation, masking and environment export logic is shared by the ModuleLocal workflows + through `.github/scripts/Expose-TestData.ps1`. +- Reference secrets as `"${{ secrets. }}"` (quoted, directly) rather than + `toJSON(secrets.)`. The direct form keeps CodeQL's *excessive secrets exposure* check happy and + works for single-line secret values. It cannot carry values that contain `"`, `\` or newlines, so + base64-encode a multi-line or special-character secret and decode it in the test (for example + `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:MY_KEY_B64))`). +- Variables use `toJSON(vars.)` so any characters are JSON-encoded safely; they are never masked. + You can use the same quoted direct form as secrets (`"${{ vars. }}"`) only for simple values + that do not contain `"`, `\` or newlines. +- Provide `TestData` as a single-line value (the folded `>-` block above does this). Avoid a literal + `|` block: GitHub registers every line of a multi-line secret as its own mask, which over-masks + unrelated log output. +- Do not pretty-print `TestData` with nested indentation. YAML preserves more-indented lines inside a + folded scalar, so a fully formatted JSON object can still become a multi-line secret. That makes + GitHub register each line as its own mask, including brace-only lines such as `{`, `}` or `},`, which + can turn unrelated log output into `***`. Keep the compact form above, or keep every JSON content + line at the same indentation level. +- Omit `TestData` entirely when the module needs no secrets or variables. Include only the map you + need (just `secrets`, just `variables`, or both). +- Because `secrets: inherit` is not used, only the values you list are ever exposed. +- Organization, repository and GitHub *Environment* secrets and variables are supported when they are + visible to the calling job. For environment-scoped values, set `environment:` on the calling job and + explicitly include those values in `TestData`; they are not exposed automatically. ### Permissions diff --git a/tests/srcTestRepo/tests/Environment.Tests.ps1 b/tests/srcTestRepo/tests/Environment.Tests.ps1 index 211be946..bc939ebc 100644 --- a/tests/srcTestRepo/tests/Environment.Tests.ps1 +++ b/tests/srcTestRepo/tests/Environment.Tests.ps1 @@ -1,15 +1,21 @@ -Describe 'Environment Variables are available' { - It 'Should be available [<_>]' -ForEach @( - 'TEST_APP_ENT_CLIENT_ID', - 'TEST_APP_ENT_PRIVATE_KEY', - 'TEST_APP_ORG_CLIENT_ID', - 'TEST_APP_ORG_PRIVATE_KEY', - 'TEST_USER_ORG_FG_PAT', - 'TEST_USER_USER_FG_PAT', - 'TEST_USER_PAT' - ) { - $name = $_ - Write-Verbose "Environment variable: [$name]" -Verbose - Get-ChildItem env: | Where-Object { $_.Name -eq $name } | Should -Not -BeNullOrEmpty +Describe 'TestData is exposed to the module tests' { + # PSMODULE_TEST_SINGLELINE_SECRET (a secret, masked) and PSMODULE_TEST_VARIABLE (a non-secret + # variable, not masked) are dedicated fixtures that exist only to prove the TestData plumbing. The + # calling workflow passes them through a single TestData object and the framework exposes them as + # environment variables; these tests confirm they arrive correct. + + It 'Exposes the single-line secret with the exact expected value' { + $expected = 'psmodule-public-nonsecret-test-fixture-single-line' + $actual = [System.Environment]::GetEnvironmentVariable('PSMODULE_TEST_SINGLELINE_SECRET') + $actual | Should -Not -BeNullOrEmpty + $actual | Should -BeExactly $expected + $actual.Length | Should -Be 50 + } + + It 'Exposes a non-secret variable via the variables map' { + $actual = [System.Environment]::GetEnvironmentVariable('PSMODULE_TEST_VARIABLE') + $actual | Should -Not -BeNullOrEmpty + $actual | Should -BeExactly 'psmodule-public-nonsecret-test-variable' + $actual.Length | Should -Be 39 } } diff --git a/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 b/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 index 211be946..bc939ebc 100644 --- a/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 +++ b/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 @@ -1,15 +1,21 @@ -Describe 'Environment Variables are available' { - It 'Should be available [<_>]' -ForEach @( - 'TEST_APP_ENT_CLIENT_ID', - 'TEST_APP_ENT_PRIVATE_KEY', - 'TEST_APP_ORG_CLIENT_ID', - 'TEST_APP_ORG_PRIVATE_KEY', - 'TEST_USER_ORG_FG_PAT', - 'TEST_USER_USER_FG_PAT', - 'TEST_USER_PAT' - ) { - $name = $_ - Write-Verbose "Environment variable: [$name]" -Verbose - Get-ChildItem env: | Where-Object { $_.Name -eq $name } | Should -Not -BeNullOrEmpty +Describe 'TestData is exposed to the module tests' { + # PSMODULE_TEST_SINGLELINE_SECRET (a secret, masked) and PSMODULE_TEST_VARIABLE (a non-secret + # variable, not masked) are dedicated fixtures that exist only to prove the TestData plumbing. The + # calling workflow passes them through a single TestData object and the framework exposes them as + # environment variables; these tests confirm they arrive correct. + + It 'Exposes the single-line secret with the exact expected value' { + $expected = 'psmodule-public-nonsecret-test-fixture-single-line' + $actual = [System.Environment]::GetEnvironmentVariable('PSMODULE_TEST_SINGLELINE_SECRET') + $actual | Should -Not -BeNullOrEmpty + $actual | Should -BeExactly $expected + $actual.Length | Should -Be 50 + } + + It 'Exposes a non-secret variable via the variables map' { + $actual = [System.Environment]::GetEnvironmentVariable('PSMODULE_TEST_VARIABLE') + $actual | Should -Not -BeNullOrEmpty + $actual | Should -BeExactly 'psmodule-public-nonsecret-test-variable' + $actual.Length | Should -Be 39 } }