From fdb8ae5b51c7daf147c98a9589a3d3195a68aa8a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 03:02:55 +0200 Subject: [PATCH 01/11] =?UTF-8?q?=F0=9F=9A=80=20[Feature]:=20Modules=20can?= =?UTF-8?q?=20expose=20caller-selected=20secrets=20to=20test=20jobs=20via?= =?UTF-8?q?=20TestSecrets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the seven hard-coded TEST_* secrets with a single optional TestSecrets JSON input. The calling workflow decides which org/repo secrets to expose by building a JSON object with toJSON(secrets.); each entry is expanded into an environment variable (multi-line safe and masked) in the BeforeAll/Test/AfterAll ModuleLocal jobs, so tests read them via $env:. No secret names are hard-coded in the shared workflow and secrets: inherit is not required. Closes #52. --- .github/workflows/AfterAll-ModuleLocal.yml | 63 ++++++++++--------- .github/workflows/BeforeAll-ModuleLocal.yml | 63 ++++++++++--------- .github/workflows/Test-ModuleLocal.yml | 61 ++++++++++-------- .github/workflows/Workflow-Test-Default.yml | 21 ++++--- .../workflows/Workflow-Test-WithManifest.yml | 21 ++++--- .github/workflows/workflow.yml | 53 ++++------------ README.md | 57 +++++++++++++---- tests/srcTestRepo/tests/Environment.Tests.ps1 | 7 +++ .../tests/Environments/Environment.Tests.ps1 | 7 +++ 9 files changed, 201 insertions(+), 152 deletions(-) diff --git a/.github/workflows/AfterAll-ModuleLocal.yml b/.github/workflows/AfterAll-ModuleLocal.yml index 2fba195f..f25c7c22 100644 --- a/.github/workflows/AfterAll-ModuleLocal.yml +++ b/.github/workflows/AfterAll-ModuleLocal.yml @@ -3,26 +3,10 @@ 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. + TestSecrets: + description: | + Optional JSON object mapping environment variable names to secret values. Each entry is + exposed as an environment variable available to the AfterAll teardown script. required: false inputs: Settings: @@ -30,15 +14,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 +30,36 @@ jobs: persist-credentials: false fetch-depth: 0 + - name: Expose caller-provided test secrets + shell: pwsh + env: + PSMODULE_TEST_SECRETS: ${{ secrets.TestSecrets }} + run: | + if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_SECRETS)) { + Write-Host 'No test secrets were provided by the calling workflow.' + return + } + try { + $secrets = $env:PSMODULE_TEST_SECRETS | ConvertFrom-Json -ErrorAction Stop + } catch { + throw "The 'TestSecrets' secret must be a JSON object mapping names to values. $_" + } + foreach ($secret in $secrets.PSObject.Properties) { + $name = $secret.Name + $value = [string]$secret.Value + foreach ($line in ($value -split "`n")) { + $line = $line.TrimEnd("`r") + if ($line.Length -gt 0) { + Write-Host "::add-mask::$line" + } + } + $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" + Add-Content -Path $env:GITHUB_ENV -Value $value + Add-Content -Path $env:GITHUB_ENV -Value $delimiter + Write-Host "Exposed [$name] as an environment variable." + } + - 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..33ffd969 100644 --- a/.github/workflows/BeforeAll-ModuleLocal.yml +++ b/.github/workflows/BeforeAll-ModuleLocal.yml @@ -3,26 +3,10 @@ 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. + TestSecrets: + description: | + Optional JSON object mapping environment variable names to secret values. Each entry is + exposed as an environment variable available to the BeforeAll setup script. required: false inputs: Settings: @@ -30,15 +14,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 +30,36 @@ jobs: persist-credentials: false fetch-depth: 0 + - name: Expose caller-provided test secrets + shell: pwsh + env: + PSMODULE_TEST_SECRETS: ${{ secrets.TestSecrets }} + run: | + if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_SECRETS)) { + Write-Host 'No test secrets were provided by the calling workflow.' + return + } + try { + $secrets = $env:PSMODULE_TEST_SECRETS | ConvertFrom-Json -ErrorAction Stop + } catch { + throw "The 'TestSecrets' secret must be a JSON object mapping names to values. $_" + } + foreach ($secret in $secrets.PSObject.Properties) { + $name = $secret.Name + $value = [string]$secret.Value + foreach ($line in ($value -split "`n")) { + $line = $line.TrimEnd("`r") + if ($line.Length -gt 0) { + Write-Host "::add-mask::$line" + } + } + $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" + Add-Content -Path $env:GITHUB_ENV -Value $value + Add-Content -Path $env:GITHUB_ENV -Value $delimiter + Write-Host "Exposed [$name] as an environment variable." + } + - name: Run BeforeAll Setup Scripts uses: PSModule/GitHub-Script@1ee97bbc652d19c38ae12f6e1e47e9d9fbd12d0a # v1.8.0 with: diff --git a/.github/workflows/Test-ModuleLocal.yml b/.github/workflows/Test-ModuleLocal.yml index 8bb87dcd..6f54df54 100644 --- a/.github/workflows/Test-ModuleLocal.yml +++ b/.github/workflows/Test-ModuleLocal.yml @@ -3,26 +3,10 @@ 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. + TestSecrets: + description: | + Optional JSON object mapping environment variable names to secret values. Each entry is + exposed as an environment variable that the module's Pester tests read via $env:. required: false inputs: Settings: @@ -34,13 +18,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 +35,36 @@ jobs: persist-credentials: false fetch-depth: 0 + - name: Expose caller-provided test secrets + shell: pwsh + env: + PSMODULE_TEST_SECRETS: ${{ secrets.TestSecrets }} + run: | + if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_SECRETS)) { + Write-Host 'No test secrets were provided by the calling workflow.' + return + } + try { + $secrets = $env:PSMODULE_TEST_SECRETS | ConvertFrom-Json -ErrorAction Stop + } catch { + throw "The 'TestSecrets' secret must be a JSON object mapping names to values. $_" + } + foreach ($secret in $secrets.PSObject.Properties) { + $name = $secret.Name + $value = [string]$secret.Value + foreach ($line in ($value -split "`n")) { + $line = $line.TrimEnd("`r") + if ($line.Length -gt 0) { + Write-Host "::add-mask::$line" + } + } + $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" + Add-Content -Path $env:GITHUB_ENV -Value $value + Add-Content -Path $env:GITHUB_ENV -Value $delimiter + Write-Host "Exposed [$name] as an environment variable." + } + - 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..a055aff1 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -28,13 +28,20 @@ jobs: 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 }} + # The calling workflow chooses which secrets to expose to the test jobs. Names are + # caller-defined and read by the module's Pester tests via $env:. CUSTOM_TEST_ENV_VAR + # is a plain literal that proves arbitrary, caller-defined names flow through end to end. + TestSecrets: | + { + "TEST_APP_ENT_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ENT_CLIENT_ID) }}, + "TEST_APP_ENT_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ENT_PRIVATE_KEY) }}, + "TEST_APP_ORG_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ORG_CLIENT_ID) }}, + "TEST_APP_ORG_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ORG_PRIVATE_KEY) }}, + "TEST_USER_ORG_FG_PAT": ${{ toJSON(secrets.TEST_USER_ORG_FG_PAT) }}, + "TEST_USER_USER_FG_PAT": ${{ toJSON(secrets.TEST_USER_USER_FG_PAT) }}, + "TEST_USER_PAT": ${{ toJSON(secrets.TEST_USER_PAT) }}, + "CUSTOM_TEST_ENV_VAR": "caller-provided-value" + } with: WorkingDirectory: tests/srcTestRepo ImportantFilePatterns: | diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 2e42981c..1c062096 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -28,13 +28,20 @@ jobs: 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 }} + # The calling workflow chooses which secrets to expose to the test jobs. Names are + # caller-defined and read by the module's Pester tests via $env:. CUSTOM_TEST_ENV_VAR + # is a plain literal that proves arbitrary, caller-defined names flow through end to end. + TestSecrets: | + { + "TEST_APP_ENT_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ENT_CLIENT_ID) }}, + "TEST_APP_ENT_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ENT_PRIVATE_KEY) }}, + "TEST_APP_ORG_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ORG_CLIENT_ID) }}, + "TEST_APP_ORG_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ORG_PRIVATE_KEY) }}, + "TEST_USER_ORG_FG_PAT": ${{ toJSON(secrets.TEST_USER_ORG_FG_PAT) }}, + "TEST_USER_USER_FG_PAT": ${{ toJSON(secrets.TEST_USER_USER_FG_PAT) }}, + "TEST_USER_PAT": ${{ toJSON(secrets.TEST_USER_PAT) }}, + "CUSTOM_TEST_ENV_VAR": "caller-provided-value" + } with: WorkingDirectory: tests/srcWithManifestTestRepo ImportantFilePatterns: | diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 9a4bc6af..14ea34d3 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -6,26 +6,15 @@ 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. + TestSecrets: + description: | + Optional JSON object mapping environment variable names to secret values that are exposed to + the module test jobs (BeforeAll-ModuleLocal, Test-ModuleLocal, AfterAll-ModuleLocal). + The calling workflow decides which secrets to provide and builds the JSON using + toJSON(secrets.) so that quoting and multi-line values are encoded correctly. Each + entry is exposed as an environment variable that the module's Pester tests read via + $env:. Values are masked in the logs. Omit it entirely when the module needs no + secrets. See the README for a complete example. required: false inputs: SettingsPath: @@ -168,13 +157,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 }} + TestSecrets: ${{ secrets.TestSecrets }} needs: - Build-Module - Get-Settings @@ -194,13 +177,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 }} + TestSecrets: ${{ secrets.TestSecrets }} with: Settings: ${{ needs.Get-Settings.outputs.Settings }} @@ -213,13 +190,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 }} + TestSecrets: ${{ secrets.TestSecrets }} needs: - Get-Settings - Test-ModuleLocal diff --git a/README.md b/README.md index 5a82eee7..84ec170c 100644 --- a/README.md +++ b/README.md @@ -397,18 +397,51 @@ 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 workflow declares only two 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 | +| `TestSecrets` | GitHub secrets | A JSON object mapping environment variable names to secret values, exposed to the module test jobs. | No | + +#### Passing secrets to the tests + +`TestSecrets` lets a module expose any number of caller-defined secrets to its test jobs +(`BeforeAll-ModuleLocal`, `Test-ModuleLocal` and `AfterAll-ModuleLocal`) without changing the shared +workflow. The calling workflow decides exactly which secrets are passed by building a JSON object. +Use `toJSON(secrets.)` so that quoting and multi-line values (such as private keys) are encoded +correctly: + +```yaml +jobs: + Process-PSModule: + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v5 + secrets: + APIKey: ${{ secrets.APIKEY }} + TestSecrets: | + { + "TEST_USER_PAT": ${{ toJSON(secrets.TEST_USER_PAT) }}, + "CONFLUENCE_API_KEY": ${{ toJSON(secrets.CONFLUENCE_API_KEY) }} + } +``` + +Each entry becomes an environment variable in the test jobs, so the module's Pester tests read the +values directly: + +```powershell +$env:TEST_USER_PAT +$env:CONFLUENCE_API_KEY +``` + +Notes: + +- The names are entirely caller-defined; no secret names are hard-coded in the shared workflow. +- Every value is masked in the logs (`::add-mask::`). +- Omit `TestSecrets` entirely when the module needs no secrets. +- Because `secrets: inherit` is not used, only the secrets you list are ever exposed. +- Organization and repository secrets are supported. Secrets stored in a GitHub *Environment* are not + exposed by this mechanism. ### Permissions diff --git a/tests/srcTestRepo/tests/Environment.Tests.ps1 b/tests/srcTestRepo/tests/Environment.Tests.ps1 index 211be946..2cf9e3fa 100644 --- a/tests/srcTestRepo/tests/Environment.Tests.ps1 +++ b/tests/srcTestRepo/tests/Environment.Tests.ps1 @@ -12,4 +12,11 @@ Write-Verbose "Environment variable: [$name]" -Verbose Get-ChildItem env: | Where-Object { $_.Name -eq $name } | Should -Not -BeNullOrEmpty } + + It 'Exposes caller-defined secret names with their values' { + # CUSTOM_TEST_ENV_VAR is provided by the calling workflow through the TestSecrets JSON object. + # It proves arbitrary, caller-defined names are plumbed through as environment variables that + # the tests read via $env:, without relying on secrets: inherit. + $env:CUSTOM_TEST_ENV_VAR | Should -Be 'caller-provided-value' + } } diff --git a/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 b/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 index 211be946..2cf9e3fa 100644 --- a/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 +++ b/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 @@ -12,4 +12,11 @@ Write-Verbose "Environment variable: [$name]" -Verbose Get-ChildItem env: | Where-Object { $_.Name -eq $name } | Should -Not -BeNullOrEmpty } + + It 'Exposes caller-defined secret names with their values' { + # CUSTOM_TEST_ENV_VAR is provided by the calling workflow through the TestSecrets JSON object. + # It proves arbitrary, caller-defined names are plumbed through as environment variables that + # the tests read via $env:, without relying on secrets: inherit. + $env:CUSTOM_TEST_ENV_VAR | Should -Be 'caller-provided-value' + } } From 772d65ff8b7c5fb732f2d02e4397fe1922c430a4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 03:36:08 +0200 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=A9=B9=20[Patch]:=20Pass=20TestSecr?= =?UTF-8?q?ets=20as=20a=20single-line=20value=20to=20prevent=20log=20over-?= =?UTF-8?q?masking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-line secret makes GitHub register every line (including standalone { and } braces) as its own mask, which over-masks unrelated log output (563 masked lines vs 25 on main). Use a folded (>-) block so the source stays readable but the secret value is a single line, registering one mask. Per-value protection is unchanged (the expose step still ::add-mask::es each value). --- .github/workflows/Workflow-Test-Default.yml | 20 ++++++++++--------- .../workflows/Workflow-Test-WithManifest.yml | 20 ++++++++++--------- .github/workflows/workflow.yml | 12 ++++++----- README.md | 13 +++++++----- 4 files changed, 37 insertions(+), 28 deletions(-) diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index a055aff1..875926db 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -31,16 +31,18 @@ jobs: # The calling workflow chooses which secrets to expose to the test jobs. Names are # caller-defined and read by the module's Pester tests via $env:. CUSTOM_TEST_ENV_VAR # is a plain literal that proves arbitrary, caller-defined names flow through end to end. - TestSecrets: | + # The folded '>-' block with flat indentation produces a SINGLE-LINE secret value; a literal + # '|' multi-line value makes GitHub register every line as its own mask and over-masks logs. + TestSecrets: >- { - "TEST_APP_ENT_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ENT_CLIENT_ID) }}, - "TEST_APP_ENT_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ENT_PRIVATE_KEY) }}, - "TEST_APP_ORG_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ORG_CLIENT_ID) }}, - "TEST_APP_ORG_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ORG_PRIVATE_KEY) }}, - "TEST_USER_ORG_FG_PAT": ${{ toJSON(secrets.TEST_USER_ORG_FG_PAT) }}, - "TEST_USER_USER_FG_PAT": ${{ toJSON(secrets.TEST_USER_USER_FG_PAT) }}, - "TEST_USER_PAT": ${{ toJSON(secrets.TEST_USER_PAT) }}, - "CUSTOM_TEST_ENV_VAR": "caller-provided-value" + "TEST_APP_ENT_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ENT_CLIENT_ID) }}, + "TEST_APP_ENT_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ENT_PRIVATE_KEY) }}, + "TEST_APP_ORG_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ORG_CLIENT_ID) }}, + "TEST_APP_ORG_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ORG_PRIVATE_KEY) }}, + "TEST_USER_ORG_FG_PAT": ${{ toJSON(secrets.TEST_USER_ORG_FG_PAT) }}, + "TEST_USER_USER_FG_PAT": ${{ toJSON(secrets.TEST_USER_USER_FG_PAT) }}, + "TEST_USER_PAT": ${{ toJSON(secrets.TEST_USER_PAT) }}, + "CUSTOM_TEST_ENV_VAR": "caller-provided-value" } with: WorkingDirectory: tests/srcTestRepo diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 1c062096..134a3fb8 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -31,16 +31,18 @@ jobs: # The calling workflow chooses which secrets to expose to the test jobs. Names are # caller-defined and read by the module's Pester tests via $env:. CUSTOM_TEST_ENV_VAR # is a plain literal that proves arbitrary, caller-defined names flow through end to end. - TestSecrets: | + # The folded '>-' block with flat indentation produces a SINGLE-LINE secret value; a literal + # '|' multi-line value makes GitHub register every line as its own mask and over-masks logs. + TestSecrets: >- { - "TEST_APP_ENT_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ENT_CLIENT_ID) }}, - "TEST_APP_ENT_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ENT_PRIVATE_KEY) }}, - "TEST_APP_ORG_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ORG_CLIENT_ID) }}, - "TEST_APP_ORG_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ORG_PRIVATE_KEY) }}, - "TEST_USER_ORG_FG_PAT": ${{ toJSON(secrets.TEST_USER_ORG_FG_PAT) }}, - "TEST_USER_USER_FG_PAT": ${{ toJSON(secrets.TEST_USER_USER_FG_PAT) }}, - "TEST_USER_PAT": ${{ toJSON(secrets.TEST_USER_PAT) }}, - "CUSTOM_TEST_ENV_VAR": "caller-provided-value" + "TEST_APP_ENT_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ENT_CLIENT_ID) }}, + "TEST_APP_ENT_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ENT_PRIVATE_KEY) }}, + "TEST_APP_ORG_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ORG_CLIENT_ID) }}, + "TEST_APP_ORG_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ORG_PRIVATE_KEY) }}, + "TEST_USER_ORG_FG_PAT": ${{ toJSON(secrets.TEST_USER_ORG_FG_PAT) }}, + "TEST_USER_USER_FG_PAT": ${{ toJSON(secrets.TEST_USER_USER_FG_PAT) }}, + "TEST_USER_PAT": ${{ toJSON(secrets.TEST_USER_PAT) }}, + "CUSTOM_TEST_ENV_VAR": "caller-provided-value" } with: WorkingDirectory: tests/srcWithManifestTestRepo diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 14ea34d3..6918b914 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -10,11 +10,13 @@ on: description: | Optional JSON object mapping environment variable names to secret values that are exposed to the module test jobs (BeforeAll-ModuleLocal, Test-ModuleLocal, AfterAll-ModuleLocal). - The calling workflow decides which secrets to provide and builds the JSON using - toJSON(secrets.) so that quoting and multi-line values are encoded correctly. Each - entry is exposed as an environment variable that the module's Pester tests read via - $env:. Values are masked in the logs. Omit it entirely when the module needs no - secrets. See the README for a complete example. + The calling workflow decides which secrets to provide and builds a single-line JSON object + using toJSON(secrets.) so that quoting and multi-line values (such as private keys) are + encoded correctly. Keep the value on a single line: a multi-line secret makes GitHub register + each line as a separate mask, which over-masks unrelated log output. Each entry is exposed as + an environment variable that the module's Pester tests read via $env:. Values are + masked in the logs. Omit it entirely when the module needs no secrets. See the README for a + complete example. required: false inputs: SettingsPath: diff --git a/README.md b/README.md index 84ec170c..df29953b 100644 --- a/README.md +++ b/README.md @@ -409,9 +409,9 @@ credentials that are exposed. `secrets: inherit` is intentionally not required. `TestSecrets` lets a module expose any number of caller-defined secrets to its test jobs (`BeforeAll-ModuleLocal`, `Test-ModuleLocal` and `AfterAll-ModuleLocal`) without changing the shared -workflow. The calling workflow decides exactly which secrets are passed by building a JSON object. +workflow. The calling workflow decides exactly which secrets are passed by building a single-line JSON object. Use `toJSON(secrets.)` so that quoting and multi-line values (such as private keys) are encoded -correctly: +correctly. A folded `>-` block keeps the source readable while producing a single-line value: ```yaml jobs: @@ -419,10 +419,10 @@ jobs: uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v5 secrets: APIKey: ${{ secrets.APIKEY }} - TestSecrets: | + TestSecrets: >- { - "TEST_USER_PAT": ${{ toJSON(secrets.TEST_USER_PAT) }}, - "CONFLUENCE_API_KEY": ${{ toJSON(secrets.CONFLUENCE_API_KEY) }} + "TEST_USER_PAT": ${{ toJSON(secrets.TEST_USER_PAT) }}, + "CONFLUENCE_API_KEY": ${{ toJSON(secrets.CONFLUENCE_API_KEY) }} } ``` @@ -438,6 +438,9 @@ Notes: - The names are entirely caller-defined; no secret names are hard-coded in the shared workflow. - Every value is masked in the logs (`::add-mask::`). +- Provide the object 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. - Omit `TestSecrets` entirely when the module needs no secrets. - Because `secrets: inherit` is not used, only the secrets you list are ever exposed. - Organization and repository secrets are supported. Secrets stored in a GitHub *Environment* are not From e5dfca3db4bcd898db160b9f5edecfea12c8d3e3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 03:52:42 +0200 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=A7=AA=20[Test]:=20Use=20throwaway?= =?UTF-8?q?=20temp=20secret=20values=20in=20self-tests=20+=20temporary=20m?= =?UTF-8?q?asking=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-tests no longer depend on real repository secrets; they pass known throwaway values through TestSecrets so masking can be verified conclusively. Adds a temporary MASKPROBE that will be removed after log verification. --- .github/workflows/Workflow-Test-Default.yml | 20 +++++------ .../workflows/Workflow-Test-WithManifest.yml | 20 +++++------ tests/srcTestRepo/tests/Environment.Tests.ps1 | 36 ++++++++++--------- .../tests/Environments/Environment.Tests.ps1 | 36 ++++++++++--------- 4 files changed, 58 insertions(+), 54 deletions(-) diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index 875926db..5ca9cecc 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -28,20 +28,20 @@ jobs: uses: ./.github/workflows/workflow.yml secrets: APIKey: ${{ secrets.APIKey }} - # The calling workflow chooses which secrets to expose to the test jobs. Names are - # caller-defined and read by the module's Pester tests via $env:. CUSTOM_TEST_ENV_VAR - # is a plain literal that proves arbitrary, caller-defined names flow through end to end. + # Self-test only: THROWAWAY, non-sensitive placeholder values (not real secrets), so the + # framework's own tests never depend on repository secrets and masking can be verified with + # known values. A real caller would use `toJSON(secrets.)` for each entry instead. # The folded '>-' block with flat indentation produces a SINGLE-LINE secret value; a literal # '|' multi-line value makes GitHub register every line as its own mask and over-masks logs. TestSecrets: >- { - "TEST_APP_ENT_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ENT_CLIENT_ID) }}, - "TEST_APP_ENT_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ENT_PRIVATE_KEY) }}, - "TEST_APP_ORG_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ORG_CLIENT_ID) }}, - "TEST_APP_ORG_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ORG_PRIVATE_KEY) }}, - "TEST_USER_ORG_FG_PAT": ${{ toJSON(secrets.TEST_USER_ORG_FG_PAT) }}, - "TEST_USER_USER_FG_PAT": ${{ toJSON(secrets.TEST_USER_USER_FG_PAT) }}, - "TEST_USER_PAT": ${{ toJSON(secrets.TEST_USER_PAT) }}, + "TEST_APP_ENT_CLIENT_ID": "tmp-ent-client-id-01", + "TEST_APP_ENT_PRIVATE_KEY": "tmp-ent-key-a1\ntmp-ent-key-a2", + "TEST_APP_ORG_CLIENT_ID": "tmp-org-client-id-02", + "TEST_APP_ORG_PRIVATE_KEY": "tmp-org-key-b1\ntmp-org-key-b2", + "TEST_USER_ORG_FG_PAT": "tmp-user-org-fgpat-03", + "TEST_USER_USER_FG_PAT": "tmp-user-usr-fgpat-04", + "TEST_USER_PAT": "tmp-user-pat-05", "CUSTOM_TEST_ENV_VAR": "caller-provided-value" } with: diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 134a3fb8..cd6ba2a1 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -28,20 +28,20 @@ jobs: uses: ./.github/workflows/workflow.yml secrets: APIKey: ${{ secrets.APIKey }} - # The calling workflow chooses which secrets to expose to the test jobs. Names are - # caller-defined and read by the module's Pester tests via $env:. CUSTOM_TEST_ENV_VAR - # is a plain literal that proves arbitrary, caller-defined names flow through end to end. + # Self-test only: THROWAWAY, non-sensitive placeholder values (not real secrets), so the + # framework's own tests never depend on repository secrets and masking can be verified with + # known values. A real caller would use `toJSON(secrets.)` for each entry instead. # The folded '>-' block with flat indentation produces a SINGLE-LINE secret value; a literal # '|' multi-line value makes GitHub register every line as its own mask and over-masks logs. TestSecrets: >- { - "TEST_APP_ENT_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ENT_CLIENT_ID) }}, - "TEST_APP_ENT_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ENT_PRIVATE_KEY) }}, - "TEST_APP_ORG_CLIENT_ID": ${{ toJSON(secrets.TEST_APP_ORG_CLIENT_ID) }}, - "TEST_APP_ORG_PRIVATE_KEY": ${{ toJSON(secrets.TEST_APP_ORG_PRIVATE_KEY) }}, - "TEST_USER_ORG_FG_PAT": ${{ toJSON(secrets.TEST_USER_ORG_FG_PAT) }}, - "TEST_USER_USER_FG_PAT": ${{ toJSON(secrets.TEST_USER_USER_FG_PAT) }}, - "TEST_USER_PAT": ${{ toJSON(secrets.TEST_USER_PAT) }}, + "TEST_APP_ENT_CLIENT_ID": "tmp-ent-client-id-01", + "TEST_APP_ENT_PRIVATE_KEY": "tmp-ent-key-a1\ntmp-ent-key-a2", + "TEST_APP_ORG_CLIENT_ID": "tmp-org-client-id-02", + "TEST_APP_ORG_PRIVATE_KEY": "tmp-org-key-b1\ntmp-org-key-b2", + "TEST_USER_ORG_FG_PAT": "tmp-user-org-fgpat-03", + "TEST_USER_USER_FG_PAT": "tmp-user-usr-fgpat-04", + "TEST_USER_PAT": "tmp-user-pat-05", "CUSTOM_TEST_ENV_VAR": "caller-provided-value" } with: diff --git a/tests/srcTestRepo/tests/Environment.Tests.ps1 b/tests/srcTestRepo/tests/Environment.Tests.ps1 index 2cf9e3fa..3a1ca1ef 100644 --- a/tests/srcTestRepo/tests/Environment.Tests.ps1 +++ b/tests/srcTestRepo/tests/Environment.Tests.ps1 @@ -1,22 +1,24 @@ -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' +# TEMP masking probe (removed after verification): deliberately emits throwaway secret values so +# the run logs can be inspected to confirm they render as *** (masked). +Write-Verbose ("MASKPROBE TEST_USER_PAT=[{0}] CUSTOM_TEST_ENV_VAR=[{1}]" -f $env:TEST_USER_PAT, $env:CUSTOM_TEST_ENV_VAR) -Verbose + +Describe 'Environment Variables are available' { + It 'Exposes [] with the caller-provided value' -ForEach @( + @{ Name = 'TEST_APP_ENT_CLIENT_ID'; Expected = 'tmp-ent-client-id-01' } + @{ Name = 'TEST_APP_ORG_CLIENT_ID'; Expected = 'tmp-org-client-id-02' } + @{ Name = 'TEST_USER_ORG_FG_PAT'; Expected = 'tmp-user-org-fgpat-03' } + @{ Name = 'TEST_USER_USER_FG_PAT'; Expected = 'tmp-user-usr-fgpat-04' } + @{ Name = 'TEST_USER_PAT'; Expected = 'tmp-user-pat-05' } + @{ Name = 'CUSTOM_TEST_ENV_VAR'; Expected = 'caller-provided-value' } ) { - $name = $_ - Write-Verbose "Environment variable: [$name]" -Verbose - Get-ChildItem env: | Where-Object { $_.Name -eq $name } | Should -Not -BeNullOrEmpty + Write-Verbose "Environment variable: [$Name]" -Verbose + [System.Environment]::GetEnvironmentVariable($Name) | Should -Be $Expected } - It 'Exposes caller-defined secret names with their values' { - # CUSTOM_TEST_ENV_VAR is provided by the calling workflow through the TestSecrets JSON object. - # It proves arbitrary, caller-defined names are plumbed through as environment variables that - # the tests read via $env:, without relying on secrets: inherit. - $env:CUSTOM_TEST_ENV_VAR | Should -Be 'caller-provided-value' + It 'Preserves multi-line values in []' -ForEach @( + @{ Name = 'TEST_APP_ENT_PRIVATE_KEY'; Expected = "tmp-ent-key-a1`ntmp-ent-key-a2" } + @{ Name = 'TEST_APP_ORG_PRIVATE_KEY'; Expected = "tmp-org-key-b1`ntmp-org-key-b2" } + ) { + [System.Environment]::GetEnvironmentVariable($Name) | Should -Be $Expected } } diff --git a/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 b/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 index 2cf9e3fa..3a1ca1ef 100644 --- a/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 +++ b/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 @@ -1,22 +1,24 @@ -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' +# TEMP masking probe (removed after verification): deliberately emits throwaway secret values so +# the run logs can be inspected to confirm they render as *** (masked). +Write-Verbose ("MASKPROBE TEST_USER_PAT=[{0}] CUSTOM_TEST_ENV_VAR=[{1}]" -f $env:TEST_USER_PAT, $env:CUSTOM_TEST_ENV_VAR) -Verbose + +Describe 'Environment Variables are available' { + It 'Exposes [] with the caller-provided value' -ForEach @( + @{ Name = 'TEST_APP_ENT_CLIENT_ID'; Expected = 'tmp-ent-client-id-01' } + @{ Name = 'TEST_APP_ORG_CLIENT_ID'; Expected = 'tmp-org-client-id-02' } + @{ Name = 'TEST_USER_ORG_FG_PAT'; Expected = 'tmp-user-org-fgpat-03' } + @{ Name = 'TEST_USER_USER_FG_PAT'; Expected = 'tmp-user-usr-fgpat-04' } + @{ Name = 'TEST_USER_PAT'; Expected = 'tmp-user-pat-05' } + @{ Name = 'CUSTOM_TEST_ENV_VAR'; Expected = 'caller-provided-value' } ) { - $name = $_ - Write-Verbose "Environment variable: [$name]" -Verbose - Get-ChildItem env: | Where-Object { $_.Name -eq $name } | Should -Not -BeNullOrEmpty + Write-Verbose "Environment variable: [$Name]" -Verbose + [System.Environment]::GetEnvironmentVariable($Name) | Should -Be $Expected } - It 'Exposes caller-defined secret names with their values' { - # CUSTOM_TEST_ENV_VAR is provided by the calling workflow through the TestSecrets JSON object. - # It proves arbitrary, caller-defined names are plumbed through as environment variables that - # the tests read via $env:, without relying on secrets: inherit. - $env:CUSTOM_TEST_ENV_VAR | Should -Be 'caller-provided-value' + It 'Preserves multi-line values in []' -ForEach @( + @{ Name = 'TEST_APP_ENT_PRIVATE_KEY'; Expected = "tmp-ent-key-a1`ntmp-ent-key-a2" } + @{ Name = 'TEST_APP_ORG_PRIVATE_KEY'; Expected = "tmp-org-key-b1`ntmp-org-key-b2" } + ) { + [System.Environment]::GetEnvironmentVariable($Name) | Should -Be $Expected } } From 93ca6f27f942c041480e56abb99723168f4f5484 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 03:59:01 +0200 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=A7=AA=20[Test]:=20Remove=20tempora?= =?UTF-8?q?ry=20masking=20probe=20after=20verifying=20secrets=20render=20a?= =?UTF-8?q?s=20***=20in=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/srcTestRepo/tests/Environment.Tests.ps1 | 6 +----- .../tests/Environments/Environment.Tests.ps1 | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/srcTestRepo/tests/Environment.Tests.ps1 b/tests/srcTestRepo/tests/Environment.Tests.ps1 index 3a1ca1ef..28569f48 100644 --- a/tests/srcTestRepo/tests/Environment.Tests.ps1 +++ b/tests/srcTestRepo/tests/Environment.Tests.ps1 @@ -1,8 +1,4 @@ -# TEMP masking probe (removed after verification): deliberately emits throwaway secret values so -# the run logs can be inspected to confirm they render as *** (masked). -Write-Verbose ("MASKPROBE TEST_USER_PAT=[{0}] CUSTOM_TEST_ENV_VAR=[{1}]" -f $env:TEST_USER_PAT, $env:CUSTOM_TEST_ENV_VAR) -Verbose - -Describe 'Environment Variables are available' { +Describe 'Environment Variables are available' { It 'Exposes [] with the caller-provided value' -ForEach @( @{ Name = 'TEST_APP_ENT_CLIENT_ID'; Expected = 'tmp-ent-client-id-01' } @{ Name = 'TEST_APP_ORG_CLIENT_ID'; Expected = 'tmp-org-client-id-02' } diff --git a/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 b/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 index 3a1ca1ef..28569f48 100644 --- a/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 +++ b/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 @@ -1,8 +1,4 @@ -# TEMP masking probe (removed after verification): deliberately emits throwaway secret values so -# the run logs can be inspected to confirm they render as *** (masked). -Write-Verbose ("MASKPROBE TEST_USER_PAT=[{0}] CUSTOM_TEST_ENV_VAR=[{1}]" -f $env:TEST_USER_PAT, $env:CUSTOM_TEST_ENV_VAR) -Verbose - -Describe 'Environment Variables are available' { +Describe 'Environment Variables are available' { It 'Exposes [] with the caller-provided value' -ForEach @( @{ Name = 'TEST_APP_ENT_CLIENT_ID'; Expected = 'tmp-ent-client-id-01' } @{ Name = 'TEST_APP_ORG_CLIENT_ID'; Expected = 'tmp-org-client-id-02' } From c501e35c926e41c914687746b1769840c3e72c45 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 04:15:02 +0200 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=A7=AA=20[Test]:=20Prove=20TestSecr?= =?UTF-8?q?ets=20plumbing=20with=20dedicated=20repo=20secrets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-tests now pass two dedicated, non-sensitive repository secrets (PSMODULE_TEST_SINGLELINE_SECRET, PSMODULE_TEST_MULTILINE_SECRET) through TestSecrets and assert the exact value and length (and multi-line integrity) in Environment.Tests.ps1. This exercises the real GitHub-secret path (masked, passed through, exposed as $env:) instead of literal placeholders. --- .github/workflows/Workflow-Test-Default.yml | 21 ++++------ .../workflows/Workflow-Test-WithManifest.yml | 21 ++++------ tests/srcTestRepo/tests/Environment.Tests.ps1 | 42 ++++++++++++------- .../tests/Environments/Environment.Tests.ps1 | 42 ++++++++++++------- 4 files changed, 68 insertions(+), 58 deletions(-) diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index 5ca9cecc..04abc5c7 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -28,21 +28,16 @@ jobs: uses: ./.github/workflows/workflow.yml secrets: APIKey: ${{ secrets.APIKey }} - # Self-test only: THROWAWAY, non-sensitive placeholder values (not real secrets), so the - # framework's own tests never depend on repository secrets and masking can be verified with - # known values. A real caller would use `toJSON(secrets.)` for each entry instead. - # The folded '>-' block with flat indentation produces a SINGLE-LINE secret value; a literal - # '|' multi-line value makes GitHub register every line as its own mask and over-masks logs. + # Self-test only: two dedicated, NON-SENSITIVE repository secrets (PSMODULE_TEST_*_SECRET) exist + # purely to prove the TestSecrets plumbing end to end - a real GitHub secret is masked, passed + # through, and exposed as $env:. Their known values are asserted (value + length) in + # tests/.../Environment.Tests.ps1. + # The folded '>-' block keeps the JSON on a SINGLE line so GitHub registers one mask for the + # blob; a multi-line blob makes every line (incl. braces) its own mask and over-masks the logs. TestSecrets: >- { - "TEST_APP_ENT_CLIENT_ID": "tmp-ent-client-id-01", - "TEST_APP_ENT_PRIVATE_KEY": "tmp-ent-key-a1\ntmp-ent-key-a2", - "TEST_APP_ORG_CLIENT_ID": "tmp-org-client-id-02", - "TEST_APP_ORG_PRIVATE_KEY": "tmp-org-key-b1\ntmp-org-key-b2", - "TEST_USER_ORG_FG_PAT": "tmp-user-org-fgpat-03", - "TEST_USER_USER_FG_PAT": "tmp-user-usr-fgpat-04", - "TEST_USER_PAT": "tmp-user-pat-05", - "CUSTOM_TEST_ENV_VAR": "caller-provided-value" + "PSMODULE_TEST_SINGLELINE_SECRET": ${{ toJSON(secrets.PSMODULE_TEST_SINGLELINE_SECRET) }}, + "PSMODULE_TEST_MULTILINE_SECRET": ${{ toJSON(secrets.PSMODULE_TEST_MULTILINE_SECRET) }} } with: WorkingDirectory: tests/srcTestRepo diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index cd6ba2a1..1c1227a4 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -28,21 +28,16 @@ jobs: uses: ./.github/workflows/workflow.yml secrets: APIKey: ${{ secrets.APIKey }} - # Self-test only: THROWAWAY, non-sensitive placeholder values (not real secrets), so the - # framework's own tests never depend on repository secrets and masking can be verified with - # known values. A real caller would use `toJSON(secrets.)` for each entry instead. - # The folded '>-' block with flat indentation produces a SINGLE-LINE secret value; a literal - # '|' multi-line value makes GitHub register every line as its own mask and over-masks logs. + # Self-test only: two dedicated, NON-SENSITIVE repository secrets (PSMODULE_TEST_*_SECRET) exist + # purely to prove the TestSecrets plumbing end to end - a real GitHub secret is masked, passed + # through, and exposed as $env:. Their known values are asserted (value + length) in + # tests/.../Environment.Tests.ps1. + # The folded '>-' block keeps the JSON on a SINGLE line so GitHub registers one mask for the + # blob; a multi-line blob makes every line (incl. braces) its own mask and over-masks the logs. TestSecrets: >- { - "TEST_APP_ENT_CLIENT_ID": "tmp-ent-client-id-01", - "TEST_APP_ENT_PRIVATE_KEY": "tmp-ent-key-a1\ntmp-ent-key-a2", - "TEST_APP_ORG_CLIENT_ID": "tmp-org-client-id-02", - "TEST_APP_ORG_PRIVATE_KEY": "tmp-org-key-b1\ntmp-org-key-b2", - "TEST_USER_ORG_FG_PAT": "tmp-user-org-fgpat-03", - "TEST_USER_USER_FG_PAT": "tmp-user-usr-fgpat-04", - "TEST_USER_PAT": "tmp-user-pat-05", - "CUSTOM_TEST_ENV_VAR": "caller-provided-value" + "PSMODULE_TEST_SINGLELINE_SECRET": ${{ toJSON(secrets.PSMODULE_TEST_SINGLELINE_SECRET) }}, + "PSMODULE_TEST_MULTILINE_SECRET": ${{ toJSON(secrets.PSMODULE_TEST_MULTILINE_SECRET) }} } with: WorkingDirectory: tests/srcWithManifestTestRepo diff --git a/tests/srcTestRepo/tests/Environment.Tests.ps1 b/tests/srcTestRepo/tests/Environment.Tests.ps1 index 28569f48..d484d211 100644 --- a/tests/srcTestRepo/tests/Environment.Tests.ps1 +++ b/tests/srcTestRepo/tests/Environment.Tests.ps1 @@ -1,20 +1,30 @@ -Describe 'Environment Variables are available' { - It 'Exposes [] with the caller-provided value' -ForEach @( - @{ Name = 'TEST_APP_ENT_CLIENT_ID'; Expected = 'tmp-ent-client-id-01' } - @{ Name = 'TEST_APP_ORG_CLIENT_ID'; Expected = 'tmp-org-client-id-02' } - @{ Name = 'TEST_USER_ORG_FG_PAT'; Expected = 'tmp-user-org-fgpat-03' } - @{ Name = 'TEST_USER_USER_FG_PAT'; Expected = 'tmp-user-usr-fgpat-04' } - @{ Name = 'TEST_USER_PAT'; Expected = 'tmp-user-pat-05' } - @{ Name = 'CUSTOM_TEST_ENV_VAR'; Expected = 'caller-provided-value' } - ) { - Write-Verbose "Environment variable: [$Name]" -Verbose - [System.Environment]::GetEnvironmentVariable($Name) | Should -Be $Expected +Describe 'TestSecrets are exposed to the module tests' { + # PSMODULE_TEST_SINGLELINE_SECRET and PSMODULE_TEST_MULTILINE_SECRET are dedicated, non-sensitive + # repository secrets that exist only to prove the TestSecrets plumbing. The calling workflow passes + # them through TestSecrets and the framework exposes them as environment variables; these tests + # confirm the values arrive present, complete and 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 'Preserves multi-line values in []' -ForEach @( - @{ Name = 'TEST_APP_ENT_PRIVATE_KEY'; Expected = "tmp-ent-key-a1`ntmp-ent-key-a2" } - @{ Name = 'TEST_APP_ORG_PRIVATE_KEY'; Expected = "tmp-org-key-b1`ntmp-org-key-b2" } - ) { - [System.Environment]::GetEnvironmentVariable($Name) | Should -Be $Expected + It 'Exposes the multi-line secret with every line intact' { + $expected = @( + 'psmodule-public-nonsecret-test-fixture-line-1' + 'psmodule-public-nonsecret-test-fixture-line-2' + 'psmodule-public-nonsecret-test-fixture-line-3' + ) -join "`n" + $actual = [System.Environment]::GetEnvironmentVariable('PSMODULE_TEST_MULTILINE_SECRET') + $actual | Should -Not -BeNullOrEmpty + $lines = $actual -split "`r?`n" + $lines.Count | Should -Be 3 + $lines[0] | Should -BeExactly 'psmodule-public-nonsecret-test-fixture-line-1' + $lines[2] | Should -BeExactly 'psmodule-public-nonsecret-test-fixture-line-3' + ($actual -replace "`r`n", "`n") | Should -BeExactly $expected + ($actual -replace "`r`n", "`n").Length | Should -Be 137 } } diff --git a/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 b/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 index 28569f48..d484d211 100644 --- a/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 +++ b/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 @@ -1,20 +1,30 @@ -Describe 'Environment Variables are available' { - It 'Exposes [] with the caller-provided value' -ForEach @( - @{ Name = 'TEST_APP_ENT_CLIENT_ID'; Expected = 'tmp-ent-client-id-01' } - @{ Name = 'TEST_APP_ORG_CLIENT_ID'; Expected = 'tmp-org-client-id-02' } - @{ Name = 'TEST_USER_ORG_FG_PAT'; Expected = 'tmp-user-org-fgpat-03' } - @{ Name = 'TEST_USER_USER_FG_PAT'; Expected = 'tmp-user-usr-fgpat-04' } - @{ Name = 'TEST_USER_PAT'; Expected = 'tmp-user-pat-05' } - @{ Name = 'CUSTOM_TEST_ENV_VAR'; Expected = 'caller-provided-value' } - ) { - Write-Verbose "Environment variable: [$Name]" -Verbose - [System.Environment]::GetEnvironmentVariable($Name) | Should -Be $Expected +Describe 'TestSecrets are exposed to the module tests' { + # PSMODULE_TEST_SINGLELINE_SECRET and PSMODULE_TEST_MULTILINE_SECRET are dedicated, non-sensitive + # repository secrets that exist only to prove the TestSecrets plumbing. The calling workflow passes + # them through TestSecrets and the framework exposes them as environment variables; these tests + # confirm the values arrive present, complete and 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 'Preserves multi-line values in []' -ForEach @( - @{ Name = 'TEST_APP_ENT_PRIVATE_KEY'; Expected = "tmp-ent-key-a1`ntmp-ent-key-a2" } - @{ Name = 'TEST_APP_ORG_PRIVATE_KEY'; Expected = "tmp-org-key-b1`ntmp-org-key-b2" } - ) { - [System.Environment]::GetEnvironmentVariable($Name) | Should -Be $Expected + It 'Exposes the multi-line secret with every line intact' { + $expected = @( + 'psmodule-public-nonsecret-test-fixture-line-1' + 'psmodule-public-nonsecret-test-fixture-line-2' + 'psmodule-public-nonsecret-test-fixture-line-3' + ) -join "`n" + $actual = [System.Environment]::GetEnvironmentVariable('PSMODULE_TEST_MULTILINE_SECRET') + $actual | Should -Not -BeNullOrEmpty + $lines = $actual -split "`r?`n" + $lines.Count | Should -Be 3 + $lines[0] | Should -BeExactly 'psmodule-public-nonsecret-test-fixture-line-1' + $lines[2] | Should -BeExactly 'psmodule-public-nonsecret-test-fixture-line-3' + ($actual -replace "`r`n", "`n") | Should -BeExactly $expected + ($actual -replace "`r`n", "`n").Length | Should -Be 137 } } From 5ae2f4cfec688512e4889c014493dd581bfe7c38 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 04:39:45 +0200 Subject: [PATCH 06/11] =?UTF-8?q?=F0=9F=9A=80=20[Feature]:=20Add=20TestVar?= =?UTF-8?q?iables=20input=20for=20non-secret=20test=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a TestVariables workflow input (symmetric to TestSecrets) that exposes caller-selected NON-SECRET values as environment variables in the module test jobs, without masking. The expose step is refactored into a shared helper that masks secrets but not variables. Self-tests prove it via a dedicated PSMODULE_TEST_VARIABLE repo variable (asserted value + length). README documents both. --- .github/workflows/AfterAll-ModuleLocal.yml | 65 ++++++++++++------- .github/workflows/BeforeAll-ModuleLocal.yml | 65 ++++++++++++------- .github/workflows/Test-ModuleLocal.yml | 65 ++++++++++++------- .github/workflows/Workflow-Test-Default.yml | 3 + .../workflows/Workflow-Test-WithManifest.yml | 3 + .github/workflows/workflow.yml | 13 ++++ README.md | 44 ++++++++----- tests/srcTestRepo/tests/Environment.Tests.ps1 | 17 +++-- .../tests/Environments/Environment.Tests.ps1 | 17 +++-- 9 files changed, 199 insertions(+), 93 deletions(-) diff --git a/.github/workflows/AfterAll-ModuleLocal.yml b/.github/workflows/AfterAll-ModuleLocal.yml index f25c7c22..96edf031 100644 --- a/.github/workflows/AfterAll-ModuleLocal.yml +++ b/.github/workflows/AfterAll-ModuleLocal.yml @@ -13,6 +13,13 @@ on: type: string description: The complete settings object including test suites. required: true + TestVariables: + type: string + description: | + Optional JSON object mapping environment variable names to NON-SECRET values, exposed as + environment variables (not masked) to the AfterAll teardown script. Built from toJSON(vars.). + required: false + default: '' permissions: contents: read # to checkout the repo @@ -30,34 +37,48 @@ jobs: persist-credentials: false fetch-depth: 0 - - name: Expose caller-provided test secrets + - name: Expose caller-provided test secrets and variables shell: pwsh env: PSMODULE_TEST_SECRETS: ${{ secrets.TestSecrets }} + PSMODULE_TEST_VARIABLES: ${{ inputs.TestVariables }} run: | - if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_SECRETS)) { - Write-Host 'No test secrets were provided by the calling workflow.' - return - } - try { - $secrets = $env:PSMODULE_TEST_SECRETS | ConvertFrom-Json -ErrorAction Stop - } catch { - throw "The 'TestSecrets' secret must be a JSON object mapping names to values. $_" - } - foreach ($secret in $secrets.PSObject.Properties) { - $name = $secret.Name - $value = [string]$secret.Value - foreach ($line in ($value -split "`n")) { - $line = $line.TrimEnd("`r") - if ($line.Length -gt 0) { - Write-Host "::add-mask::$line" + function Add-EnvFromJson { + param( + [string] $Json, + [string] $Source, + [switch] $Mask + ) + if ([string]::IsNullOrWhiteSpace($Json)) { return } + try { + $items = $Json | ConvertFrom-Json -ErrorAction Stop + } catch { + throw "The '$Source' value must be a JSON object mapping names to values. $_" + } + foreach ($item in $items.PSObject.Properties) { + $name = $item.Name + $value = [string]$item.Value + if ($Mask) { + foreach ($line in ($value -split "`n")) { + $line = $line.TrimEnd("`r") + if ($line.Length -gt 0) { + Write-Host "::add-mask::$line" + } + } } + $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" + Add-Content -Path $env:GITHUB_ENV -Value $value + Add-Content -Path $env:GITHUB_ENV -Value $delimiter + Write-Host "Exposed [$name] as an environment variable." } - $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" - Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" - Add-Content -Path $env:GITHUB_ENV -Value $value - Add-Content -Path $env:GITHUB_ENV -Value $delimiter - Write-Host "Exposed [$name] as an environment variable." + } + + Add-EnvFromJson -Json $env:PSMODULE_TEST_SECRETS -Source 'TestSecrets' -Mask + Add-EnvFromJson -Json $env:PSMODULE_TEST_VARIABLES -Source 'TestVariables' + + if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_SECRETS) -and [string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_VARIABLES)) { + Write-Host 'No test secrets or variables were provided by the calling workflow.' } - name: Run AfterAll Teardown Scripts diff --git a/.github/workflows/BeforeAll-ModuleLocal.yml b/.github/workflows/BeforeAll-ModuleLocal.yml index 33ffd969..1cb5786c 100644 --- a/.github/workflows/BeforeAll-ModuleLocal.yml +++ b/.github/workflows/BeforeAll-ModuleLocal.yml @@ -13,6 +13,13 @@ on: type: string description: The complete settings object including test suites. required: true + TestVariables: + type: string + description: | + Optional JSON object mapping environment variable names to NON-SECRET values, exposed as + environment variables (not masked) to the BeforeAll setup script. Built from toJSON(vars.). + required: false + default: '' permissions: contents: read # to checkout the repo @@ -30,34 +37,48 @@ jobs: persist-credentials: false fetch-depth: 0 - - name: Expose caller-provided test secrets + - name: Expose caller-provided test secrets and variables shell: pwsh env: PSMODULE_TEST_SECRETS: ${{ secrets.TestSecrets }} + PSMODULE_TEST_VARIABLES: ${{ inputs.TestVariables }} run: | - if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_SECRETS)) { - Write-Host 'No test secrets were provided by the calling workflow.' - return - } - try { - $secrets = $env:PSMODULE_TEST_SECRETS | ConvertFrom-Json -ErrorAction Stop - } catch { - throw "The 'TestSecrets' secret must be a JSON object mapping names to values. $_" - } - foreach ($secret in $secrets.PSObject.Properties) { - $name = $secret.Name - $value = [string]$secret.Value - foreach ($line in ($value -split "`n")) { - $line = $line.TrimEnd("`r") - if ($line.Length -gt 0) { - Write-Host "::add-mask::$line" + function Add-EnvFromJson { + param( + [string] $Json, + [string] $Source, + [switch] $Mask + ) + if ([string]::IsNullOrWhiteSpace($Json)) { return } + try { + $items = $Json | ConvertFrom-Json -ErrorAction Stop + } catch { + throw "The '$Source' value must be a JSON object mapping names to values. $_" + } + foreach ($item in $items.PSObject.Properties) { + $name = $item.Name + $value = [string]$item.Value + if ($Mask) { + foreach ($line in ($value -split "`n")) { + $line = $line.TrimEnd("`r") + if ($line.Length -gt 0) { + Write-Host "::add-mask::$line" + } + } } + $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" + Add-Content -Path $env:GITHUB_ENV -Value $value + Add-Content -Path $env:GITHUB_ENV -Value $delimiter + Write-Host "Exposed [$name] as an environment variable." } - $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" - Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" - Add-Content -Path $env:GITHUB_ENV -Value $value - Add-Content -Path $env:GITHUB_ENV -Value $delimiter - Write-Host "Exposed [$name] as an environment variable." + } + + Add-EnvFromJson -Json $env:PSMODULE_TEST_SECRETS -Source 'TestSecrets' -Mask + Add-EnvFromJson -Json $env:PSMODULE_TEST_VARIABLES -Source 'TestVariables' + + if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_SECRETS) -and [string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_VARIABLES)) { + Write-Host 'No test secrets or variables were provided by the calling workflow.' } - name: Run BeforeAll Setup Scripts diff --git a/.github/workflows/Test-ModuleLocal.yml b/.github/workflows/Test-ModuleLocal.yml index 6f54df54..9e52c094 100644 --- a/.github/workflows/Test-ModuleLocal.yml +++ b/.github/workflows/Test-ModuleLocal.yml @@ -13,6 +13,13 @@ on: type: string description: The settings object as a JSON string. required: true + TestVariables: + type: string + description: | + Optional JSON object mapping environment variable names to NON-SECRET values, exposed as + environment variables (not masked). The caller builds it from toJSON(vars.). + required: false + default: '' permissions: contents: read # to checkout the repo and create releases on the repo @@ -35,34 +42,48 @@ jobs: persist-credentials: false fetch-depth: 0 - - name: Expose caller-provided test secrets + - name: Expose caller-provided test secrets and variables shell: pwsh env: PSMODULE_TEST_SECRETS: ${{ secrets.TestSecrets }} + PSMODULE_TEST_VARIABLES: ${{ inputs.TestVariables }} run: | - if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_SECRETS)) { - Write-Host 'No test secrets were provided by the calling workflow.' - return - } - try { - $secrets = $env:PSMODULE_TEST_SECRETS | ConvertFrom-Json -ErrorAction Stop - } catch { - throw "The 'TestSecrets' secret must be a JSON object mapping names to values. $_" - } - foreach ($secret in $secrets.PSObject.Properties) { - $name = $secret.Name - $value = [string]$secret.Value - foreach ($line in ($value -split "`n")) { - $line = $line.TrimEnd("`r") - if ($line.Length -gt 0) { - Write-Host "::add-mask::$line" + function Add-EnvFromJson { + param( + [string] $Json, + [string] $Source, + [switch] $Mask + ) + if ([string]::IsNullOrWhiteSpace($Json)) { return } + try { + $items = $Json | ConvertFrom-Json -ErrorAction Stop + } catch { + throw "The '$Source' value must be a JSON object mapping names to values. $_" + } + foreach ($item in $items.PSObject.Properties) { + $name = $item.Name + $value = [string]$item.Value + if ($Mask) { + foreach ($line in ($value -split "`n")) { + $line = $line.TrimEnd("`r") + if ($line.Length -gt 0) { + Write-Host "::add-mask::$line" + } + } } + $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" + Add-Content -Path $env:GITHUB_ENV -Value $value + Add-Content -Path $env:GITHUB_ENV -Value $delimiter + Write-Host "Exposed [$name] as an environment variable." } - $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" - Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" - Add-Content -Path $env:GITHUB_ENV -Value $value - Add-Content -Path $env:GITHUB_ENV -Value $delimiter - Write-Host "Exposed [$name] as an environment variable." + } + + Add-EnvFromJson -Json $env:PSMODULE_TEST_SECRETS -Source 'TestSecrets' -Mask + Add-EnvFromJson -Json $env:PSMODULE_TEST_VARIABLES -Source 'TestVariables' + + if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_SECRETS) -and [string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_VARIABLES)) { + Write-Host 'No test secrets or variables were provided by the calling workflow.' } - name: Download module artifact diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index 04abc5c7..0af8643a 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -41,6 +41,9 @@ jobs: } with: WorkingDirectory: tests/srcTestRepo + # Non-secret configuration flows via TestVariables (built from repo/org variables, not masked). + TestVariables: >- + { "PSMODULE_TEST_VARIABLE": ${{ toJSON(vars.PSMODULE_TEST_VARIABLE) }} } ImportantFilePatterns: | ^src/ ^README\.md$ diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 1c1227a4..7ef39279 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -41,6 +41,9 @@ jobs: } with: WorkingDirectory: tests/srcWithManifestTestRepo + # Non-secret configuration flows via TestVariables (built from repo/org variables, not masked). + TestVariables: >- + { "PSMODULE_TEST_VARIABLE": ${{ toJSON(vars.PSMODULE_TEST_VARIABLE) }} } ImportantFilePatterns: | ^src/ ^README\.md$ diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 6918b914..ab237f27 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -59,6 +59,16 @@ on: default: | ^src/ ^README\.md$ + TestVariables: + type: string + description: | + Optional JSON object mapping environment variable names to NON-SECRET values that are exposed + to the module test jobs (BeforeAll-ModuleLocal, Test-ModuleLocal, AfterAll-ModuleLocal). + The calling workflow builds it from repository/organization variables using + toJSON(vars.). Unlike TestSecrets, these values are NOT masked. Omit it when the module + needs no non-secret test configuration. See the README for a complete example. + required: false + default: '' permissions: contents: write # to checkout the repo and create releases on the repo @@ -165,6 +175,7 @@ jobs: - Get-Settings with: Settings: ${{ needs.Get-Settings.outputs.Settings }} + TestVariables: ${{ inputs.TestVariables }} # Runs on: # - ✅ Open/Updated PR - Tests module in local environment @@ -182,6 +193,7 @@ jobs: TestSecrets: ${{ secrets.TestSecrets }} with: Settings: ${{ needs.Get-Settings.outputs.Settings }} + TestVariables: ${{ inputs.TestVariables }} # Runs on: # - ✅ Open/Updated PR - Runs teardown scripts after local module tests @@ -198,6 +210,7 @@ jobs: - Test-ModuleLocal with: Settings: ${{ needs.Get-Settings.outputs.Settings }} + TestVariables: ${{ inputs.TestVariables }} # Runs on: # - ✅ Open/Updated PR - Collects and reports test results diff --git a/README.md b/README.md index df29953b..82245e5f 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,7 @@ jobs: | `Prerelease` | `boolean` | Whether to use a prerelease version of the 'GitHub' module. | `false` | `false` | | `WorkingDirectory` | `string` | The path to the root of the repo. | `false` | `'.'` | | `ImportantFilePatterns` | `string` | Newline-separated list of regular expression patterns that identify important files. Changes matching these patterns trigger build, test, and publish stages. When set, fully replaces the defaults. | `false` | `^src/\n^README\.md$` | +| `TestVariables` | `string` | A JSON object mapping environment variable names to non-secret values, exposed (unmasked) as environment variables to the module test jobs. Built by the caller from `toJSON(vars.)`. | `false` | `''` | ### Secrets @@ -405,13 +406,16 @@ credentials that are exposed. `secrets: inherit` is intentionally not required. | `APIKey` | GitHub secrets | The API key for the PowerShell Gallery, used to publish the module. | Yes | | `TestSecrets` | GitHub secrets | A JSON object mapping environment variable names to secret values, exposed to the module test jobs. | No | -#### Passing secrets to the tests +#### Passing secrets and variables to the tests -`TestSecrets` lets a module expose any number of caller-defined secrets to its test jobs -(`BeforeAll-ModuleLocal`, `Test-ModuleLocal` and `AfterAll-ModuleLocal`) without changing the shared -workflow. The calling workflow decides exactly which secrets are passed by building a single-line JSON object. -Use `toJSON(secrets.)` so that quoting and multi-line values (such as private keys) are encoded -correctly. A folded `>-` block keeps the source readable while producing a single-line value: +`TestSecrets` (a secret) and `TestVariables` (a regular input) let 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. Use `TestSecrets` for sensitive values +(masked in the logs) and `TestVariables` for non-secret configuration such as URLs, usernames and +identifiers (not masked). The calling workflow decides exactly what is passed by building single-line +JSON objects. Use `toJSON(secrets.)` / `toJSON(vars.)` so that quoting and multi-line +values (such as private keys) are encoded correctly. A folded `>-` block keeps the source readable +while producing a single-line value: ```yaml jobs: @@ -421,8 +425,14 @@ jobs: APIKey: ${{ secrets.APIKEY }} TestSecrets: >- { - "TEST_USER_PAT": ${{ toJSON(secrets.TEST_USER_PAT) }}, - "CONFLUENCE_API_KEY": ${{ toJSON(secrets.CONFLUENCE_API_KEY) }} + "CONFLUENCE_API_TOKEN": ${{ toJSON(secrets.CONFLUENCE_API_TOKEN) }} + } + with: + TestVariables: >- + { + "CONFLUENCE_API_BASE_URI": ${{ toJSON(vars.CONFLUENCE_API_BASE_URI) }}, + "CONFLUENCE_USERNAME": ${{ toJSON(vars.CONFLUENCE_USERNAME) }}, + "CONFLUENCE_SPACE_KEY": ${{ toJSON(vars.CONFLUENCE_SPACE_KEY) }} } ``` @@ -430,21 +440,21 @@ Each entry becomes an environment variable in the test jobs, so the module's Pes values directly: ```powershell -$env:TEST_USER_PAT -$env:CONFLUENCE_API_KEY +$env:CONFLUENCE_API_TOKEN # from TestSecrets (masked in logs) +$env:CONFLUENCE_API_BASE_URI # from TestVariables (not masked) ``` Notes: -- The names are entirely caller-defined; no secret names are hard-coded in the shared workflow. -- Every value is masked in the logs (`::add-mask::`). -- Provide the object as a single-line value (the folded `>-` block above does this). Avoid a literal +- The names are entirely caller-defined; no secret or variable names are hard-coded in the shared workflow. +- `TestSecrets` values are masked in the logs (`::add-mask::`); `TestVariables` values are not masked. +- Provide each object 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. -- Omit `TestSecrets` entirely when the module needs no secrets. -- Because `secrets: inherit` is not used, only the secrets you list are ever exposed. -- Organization and repository secrets are supported. Secrets stored in a GitHub *Environment* are not - exposed by this mechanism. +- Omit `TestSecrets` / `TestVariables` entirely when the module needs no secrets / no non-secret config. +- Because `secrets: inherit` is not used, only the values you list are ever exposed. +- Organization and repository secrets and variables are supported. Secrets stored in a GitHub + *Environment* are not exposed by this mechanism. ### Permissions diff --git a/tests/srcTestRepo/tests/Environment.Tests.ps1 b/tests/srcTestRepo/tests/Environment.Tests.ps1 index d484d211..ea030fbf 100644 --- a/tests/srcTestRepo/tests/Environment.Tests.ps1 +++ b/tests/srcTestRepo/tests/Environment.Tests.ps1 @@ -1,8 +1,8 @@ -Describe 'TestSecrets are exposed to the module tests' { - # PSMODULE_TEST_SINGLELINE_SECRET and PSMODULE_TEST_MULTILINE_SECRET are dedicated, non-sensitive - # repository secrets that exist only to prove the TestSecrets plumbing. The calling workflow passes - # them through TestSecrets and the framework exposes them as environment variables; these tests - # confirm the values arrive present, complete and correct. +Describe 'TestSecrets and TestVariables are exposed to the module tests' { + # PSMODULE_TEST_SINGLELINE_SECRET / PSMODULE_TEST_MULTILINE_SECRET (secrets, masked) and + # PSMODULE_TEST_VARIABLE (a non-secret variable, not masked) are dedicated fixtures that exist only + # to prove the TestSecrets/TestVariables plumbing. The calling workflow passes them through 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' @@ -27,4 +27,11 @@ ($actual -replace "`r`n", "`n") | Should -BeExactly $expected ($actual -replace "`r`n", "`n").Length | Should -Be 137 } + + It 'Exposes a non-secret variable via TestVariables' { + $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 d484d211..ea030fbf 100644 --- a/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 +++ b/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 @@ -1,8 +1,8 @@ -Describe 'TestSecrets are exposed to the module tests' { - # PSMODULE_TEST_SINGLELINE_SECRET and PSMODULE_TEST_MULTILINE_SECRET are dedicated, non-sensitive - # repository secrets that exist only to prove the TestSecrets plumbing. The calling workflow passes - # them through TestSecrets and the framework exposes them as environment variables; these tests - # confirm the values arrive present, complete and correct. +Describe 'TestSecrets and TestVariables are exposed to the module tests' { + # PSMODULE_TEST_SINGLELINE_SECRET / PSMODULE_TEST_MULTILINE_SECRET (secrets, masked) and + # PSMODULE_TEST_VARIABLE (a non-secret variable, not masked) are dedicated fixtures that exist only + # to prove the TestSecrets/TestVariables plumbing. The calling workflow passes them through 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' @@ -27,4 +27,11 @@ ($actual -replace "`r`n", "`n") | Should -BeExactly $expected ($actual -replace "`r`n", "`n").Length | Should -Be 137 } + + It 'Exposes a non-secret variable via TestVariables' { + $actual = [System.Environment]::GetEnvironmentVariable('PSMODULE_TEST_VARIABLE') + $actual | Should -Not -BeNullOrEmpty + $actual | Should -BeExactly 'psmodule-public-nonsecret-test-variable' + $actual.Length | Should -Be 39 + } } From ea019351821a74ae1528a4472198c84c91dd3059 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 11:29:20 +0200 Subject: [PATCH 07/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[Maintenance]:=20Com?= =?UTF-8?q?bine=20TestSecrets=20and=20TestVariables=20into=20a=20single=20?= =?UTF-8?q?TestData=20secret?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the separate TestSecrets secret and TestVariables input with one TestData secret that carries a { "secrets": {...}, "variables": {...} } object, so callers have a single overview of everything the module tests receive. - workflow.yml + the three ModuleLocal workflows declare/pass TestData; the expose step parses both maps, masks only the "secrets" values, and leaves "variables" unmasked. - Self-test callers build TestData with the CodeQL-clean direct "${{ secrets.X }}" form for secrets and toJSON(vars.X) for variables, kept single-line via a folded >- scalar so GitHub registers one mask. - Drop the multi-line self-test secret (the direct form cannot carry newlines); README documents base64 for multi-line/special-character secrets. - Update Environment.Tests.ps1 and the README accordingly. --- .github/workflows/AfterAll-ModuleLocal.yml | 50 +++++++---------- .github/workflows/BeforeAll-ModuleLocal.yml | 50 +++++++---------- .github/workflows/Test-ModuleLocal.yml | 50 +++++++---------- .github/workflows/Workflow-Test-Default.yml | 23 ++++---- .../workflows/Workflow-Test-WithManifest.yml | 23 ++++---- .github/workflows/workflow.yml | 46 ++++++---------- README.md | 55 ++++++++++--------- tests/srcTestRepo/tests/Environment.Tests.ps1 | 28 ++-------- .../tests/Environments/Environment.Tests.ps1 | 28 ++-------- 9 files changed, 140 insertions(+), 213 deletions(-) diff --git a/.github/workflows/AfterAll-ModuleLocal.yml b/.github/workflows/AfterAll-ModuleLocal.yml index 96edf031..5cc0edf1 100644 --- a/.github/workflows/AfterAll-ModuleLocal.yml +++ b/.github/workflows/AfterAll-ModuleLocal.yml @@ -3,23 +3,17 @@ name: AfterAll-ModuleLocal on: workflow_call: secrets: - TestSecrets: + TestData: description: | - Optional JSON object mapping environment variable names to secret values. Each entry is - exposed as an environment variable available to the AfterAll teardown script. + 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: type: string description: The complete settings object including test suites. required: true - TestVariables: - type: string - description: | - Optional JSON object mapping environment variable names to NON-SECRET values, exposed as - environment variables (not masked) to the AfterAll teardown script. Built from toJSON(vars.). - required: false - default: '' permissions: contents: read # to checkout the repo @@ -37,25 +31,27 @@ jobs: persist-credentials: false fetch-depth: 0 - - name: Expose caller-provided test secrets and variables + - name: Expose caller-provided test data shell: pwsh env: - PSMODULE_TEST_SECRETS: ${{ secrets.TestSecrets }} - PSMODULE_TEST_VARIABLES: ${{ inputs.TestVariables }} + PSMODULE_TEST_DATA: ${{ secrets.TestData }} run: | - function Add-EnvFromJson { + if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_DATA)) { + Write-Host '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 a JSON object with 'secrets' and/or 'variables' maps. $_" + } + function Add-EnvFromMap { param( - [string] $Json, - [string] $Source, + [object] $Map, [switch] $Mask ) - if ([string]::IsNullOrWhiteSpace($Json)) { return } - try { - $items = $Json | ConvertFrom-Json -ErrorAction Stop - } catch { - throw "The '$Source' value must be a JSON object mapping names to values. $_" - } - foreach ($item in $items.PSObject.Properties) { + if (-not $Map) { return } + foreach ($item in $Map.PSObject.Properties) { $name = $item.Name $value = [string]$item.Value if ($Mask) { @@ -74,12 +70,8 @@ jobs: } } - Add-EnvFromJson -Json $env:PSMODULE_TEST_SECRETS -Source 'TestSecrets' -Mask - Add-EnvFromJson -Json $env:PSMODULE_TEST_VARIABLES -Source 'TestVariables' - - if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_SECRETS) -and [string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_VARIABLES)) { - Write-Host 'No test secrets or variables were provided by the calling workflow.' - } + Add-EnvFromMap -Map $data.secrets -Mask + Add-EnvFromMap -Map $data.variables - name: Run AfterAll Teardown Scripts if: always() diff --git a/.github/workflows/BeforeAll-ModuleLocal.yml b/.github/workflows/BeforeAll-ModuleLocal.yml index 1cb5786c..067431e6 100644 --- a/.github/workflows/BeforeAll-ModuleLocal.yml +++ b/.github/workflows/BeforeAll-ModuleLocal.yml @@ -3,23 +3,17 @@ name: BeforeAll-ModuleLocal on: workflow_call: secrets: - TestSecrets: + TestData: description: | - Optional JSON object mapping environment variable names to secret values. Each entry is - exposed as an environment variable available to the BeforeAll setup script. + 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: type: string description: The complete settings object including test suites. required: true - TestVariables: - type: string - description: | - Optional JSON object mapping environment variable names to NON-SECRET values, exposed as - environment variables (not masked) to the BeforeAll setup script. Built from toJSON(vars.). - required: false - default: '' permissions: contents: read # to checkout the repo @@ -37,25 +31,27 @@ jobs: persist-credentials: false fetch-depth: 0 - - name: Expose caller-provided test secrets and variables + - name: Expose caller-provided test data shell: pwsh env: - PSMODULE_TEST_SECRETS: ${{ secrets.TestSecrets }} - PSMODULE_TEST_VARIABLES: ${{ inputs.TestVariables }} + PSMODULE_TEST_DATA: ${{ secrets.TestData }} run: | - function Add-EnvFromJson { + if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_DATA)) { + Write-Host '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 a JSON object with 'secrets' and/or 'variables' maps. $_" + } + function Add-EnvFromMap { param( - [string] $Json, - [string] $Source, + [object] $Map, [switch] $Mask ) - if ([string]::IsNullOrWhiteSpace($Json)) { return } - try { - $items = $Json | ConvertFrom-Json -ErrorAction Stop - } catch { - throw "The '$Source' value must be a JSON object mapping names to values. $_" - } - foreach ($item in $items.PSObject.Properties) { + if (-not $Map) { return } + foreach ($item in $Map.PSObject.Properties) { $name = $item.Name $value = [string]$item.Value if ($Mask) { @@ -74,12 +70,8 @@ jobs: } } - Add-EnvFromJson -Json $env:PSMODULE_TEST_SECRETS -Source 'TestSecrets' -Mask - Add-EnvFromJson -Json $env:PSMODULE_TEST_VARIABLES -Source 'TestVariables' - - if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_SECRETS) -and [string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_VARIABLES)) { - Write-Host 'No test secrets or variables were provided by the calling workflow.' - } + Add-EnvFromMap -Map $data.secrets -Mask + Add-EnvFromMap -Map $data.variables - name: Run BeforeAll Setup Scripts uses: PSModule/GitHub-Script@1ee97bbc652d19c38ae12f6e1e47e9d9fbd12d0a # v1.8.0 diff --git a/.github/workflows/Test-ModuleLocal.yml b/.github/workflows/Test-ModuleLocal.yml index 9e52c094..0ddcc35e 100644 --- a/.github/workflows/Test-ModuleLocal.yml +++ b/.github/workflows/Test-ModuleLocal.yml @@ -3,23 +3,17 @@ name: Test-ModuleLocal on: workflow_call: secrets: - TestSecrets: + TestData: description: | - Optional JSON object mapping environment variable names to secret values. Each entry is - exposed as an environment variable that the module's Pester tests read via $env:. + 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: type: string description: The settings object as a JSON string. required: true - TestVariables: - type: string - description: | - Optional JSON object mapping environment variable names to NON-SECRET values, exposed as - environment variables (not masked). The caller builds it from toJSON(vars.). - required: false - default: '' permissions: contents: read # to checkout the repo and create releases on the repo @@ -42,25 +36,27 @@ jobs: persist-credentials: false fetch-depth: 0 - - name: Expose caller-provided test secrets and variables + - name: Expose caller-provided test data shell: pwsh env: - PSMODULE_TEST_SECRETS: ${{ secrets.TestSecrets }} - PSMODULE_TEST_VARIABLES: ${{ inputs.TestVariables }} + PSMODULE_TEST_DATA: ${{ secrets.TestData }} run: | - function Add-EnvFromJson { + if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_DATA)) { + Write-Host '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 a JSON object with 'secrets' and/or 'variables' maps. $_" + } + function Add-EnvFromMap { param( - [string] $Json, - [string] $Source, + [object] $Map, [switch] $Mask ) - if ([string]::IsNullOrWhiteSpace($Json)) { return } - try { - $items = $Json | ConvertFrom-Json -ErrorAction Stop - } catch { - throw "The '$Source' value must be a JSON object mapping names to values. $_" - } - foreach ($item in $items.PSObject.Properties) { + if (-not $Map) { return } + foreach ($item in $Map.PSObject.Properties) { $name = $item.Name $value = [string]$item.Value if ($Mask) { @@ -79,12 +75,8 @@ jobs: } } - Add-EnvFromJson -Json $env:PSMODULE_TEST_SECRETS -Source 'TestSecrets' -Mask - Add-EnvFromJson -Json $env:PSMODULE_TEST_VARIABLES -Source 'TestVariables' - - if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_SECRETS) -and [string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_VARIABLES)) { - Write-Host 'No test secrets or variables were provided by the calling workflow.' - } + Add-EnvFromMap -Map $data.secrets -Mask + Add-EnvFromMap -Map $data.variables - name: Download module artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index 0af8643a..7ba93a91 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -28,22 +28,19 @@ jobs: uses: ./.github/workflows/workflow.yml secrets: APIKey: ${{ secrets.APIKey }} - # Self-test only: two dedicated, NON-SENSITIVE repository secrets (PSMODULE_TEST_*_SECRET) exist - # purely to prove the TestSecrets plumbing end to end - a real GitHub secret is masked, passed - # through, and exposed as $env:. Their known values are asserted (value + length) in + # 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. - # The folded '>-' block keeps the JSON on a SINGLE line so GitHub registers one mask for the - # blob; a multi-line blob makes every line (incl. braces) its own mask and over-masks the logs. - TestSecrets: >- - { - "PSMODULE_TEST_SINGLELINE_SECRET": ${{ toJSON(secrets.PSMODULE_TEST_SINGLELINE_SECRET) }}, - "PSMODULE_TEST_MULTILINE_SECRET": ${{ toJSON(secrets.PSMODULE_TEST_MULTILINE_SECRET) }} - } + # 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 - # Non-secret configuration flows via TestVariables (built from repo/org variables, not masked). - TestVariables: >- - { "PSMODULE_TEST_VARIABLE": ${{ toJSON(vars.PSMODULE_TEST_VARIABLE) }} } ImportantFilePatterns: | ^src/ ^README\.md$ diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 7ef39279..3e636b5f 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -28,22 +28,19 @@ jobs: uses: ./.github/workflows/workflow.yml secrets: APIKey: ${{ secrets.APIKey }} - # Self-test only: two dedicated, NON-SENSITIVE repository secrets (PSMODULE_TEST_*_SECRET) exist - # purely to prove the TestSecrets plumbing end to end - a real GitHub secret is masked, passed - # through, and exposed as $env:. Their known values are asserted (value + length) in + # 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. - # The folded '>-' block keeps the JSON on a SINGLE line so GitHub registers one mask for the - # blob; a multi-line blob makes every line (incl. braces) its own mask and over-masks the logs. - TestSecrets: >- - { - "PSMODULE_TEST_SINGLELINE_SECRET": ${{ toJSON(secrets.PSMODULE_TEST_SINGLELINE_SECRET) }}, - "PSMODULE_TEST_MULTILINE_SECRET": ${{ toJSON(secrets.PSMODULE_TEST_MULTILINE_SECRET) }} - } + # 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 - # Non-secret configuration flows via TestVariables (built from repo/org variables, not masked). - TestVariables: >- - { "PSMODULE_TEST_VARIABLE": ${{ toJSON(vars.PSMODULE_TEST_VARIABLE) }} } ImportantFilePatterns: | ^src/ ^README\.md$ diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index ab237f27..621e49f1 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -6,17 +6,16 @@ on: APIKey: description: The API key for the PowerShell Gallery. required: true - TestSecrets: + TestData: description: | - Optional JSON object mapping environment variable names to secret values that are exposed to - the module test jobs (BeforeAll-ModuleLocal, Test-ModuleLocal, AfterAll-ModuleLocal). - The calling workflow decides which secrets to provide and builds a single-line JSON object - using toJSON(secrets.) so that quoting and multi-line values (such as private keys) are - encoded correctly. Keep the value on a single line: a multi-line secret makes GitHub register - each line as a separate mask, which over-masks unrelated log output. Each entry is exposed as - an environment variable that the module's Pester tests read via $env:. Values are - masked in the logs. Omit it entirely when the module needs no secrets. See the README for a - complete example. + 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: @@ -59,23 +58,13 @@ on: default: | ^src/ ^README\.md$ - TestVariables: - type: string - description: | - Optional JSON object mapping environment variable names to NON-SECRET values that are exposed - to the module test jobs (BeforeAll-ModuleLocal, Test-ModuleLocal, AfterAll-ModuleLocal). - The calling workflow builds it from repository/organization variables using - toJSON(vars.). Unlike TestSecrets, these values are NOT masked. Omit it when the module - needs no non-secret test configuration. See the README for a complete example. - required: false - default: '' 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: @@ -169,13 +158,12 @@ jobs: if: fromJson(needs.Get-Settings.outputs.Settings).Run.BeforeAllModuleLocal && needs.Build-Module.result == 'success' && !cancelled() uses: ./.github/workflows/BeforeAll-ModuleLocal.yml secrets: - TestSecrets: ${{ secrets.TestSecrets }} + TestData: ${{ secrets.TestData }} needs: - Build-Module - Get-Settings with: Settings: ${{ needs.Get-Settings.outputs.Settings }} - TestVariables: ${{ inputs.TestVariables }} # Runs on: # - ✅ Open/Updated PR - Tests module in local environment @@ -190,10 +178,9 @@ jobs: - BeforeAll-ModuleLocal uses: ./.github/workflows/Test-ModuleLocal.yml secrets: - TestSecrets: ${{ secrets.TestSecrets }} + TestData: ${{ secrets.TestData }} with: Settings: ${{ needs.Get-Settings.outputs.Settings }} - TestVariables: ${{ inputs.TestVariables }} # Runs on: # - ✅ Open/Updated PR - Runs teardown scripts after local module tests @@ -204,13 +191,12 @@ jobs: if: fromJson(needs.Get-Settings.outputs.Settings).Run.AfterAllModuleLocal && needs.Test-ModuleLocal.result != 'skipped' && always() uses: ./.github/workflows/AfterAll-ModuleLocal.yml secrets: - TestSecrets: ${{ secrets.TestSecrets }} + TestData: ${{ secrets.TestData }} needs: - Get-Settings - Test-ModuleLocal with: Settings: ${{ needs.Get-Settings.outputs.Settings }} - TestVariables: ${{ inputs.TestVariables }} # Runs on: # - ✅ Open/Updated PR - Collects and reports test results diff --git a/README.md b/README.md index 82245e5f..f7072041 100644 --- a/README.md +++ b/README.md @@ -394,7 +394,6 @@ jobs: | `Prerelease` | `boolean` | Whether to use a prerelease version of the 'GitHub' module. | `false` | `false` | | `WorkingDirectory` | `string` | The path to the root of the repo. | `false` | `'.'` | | `ImportantFilePatterns` | `string` | Newline-separated list of regular expression patterns that identify important files. Changes matching these patterns trigger build, test, and publish stages. When set, fully replaces the defaults. | `false` | `^src/\n^README\.md$` | -| `TestVariables` | `string` | A JSON object mapping environment variable names to non-secret values, exposed (unmasked) as environment variables to the module test jobs. Built by the caller from `toJSON(vars.)`. | `false` | `''` | ### Secrets @@ -404,18 +403,22 @@ 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 | -| `TestSecrets` | GitHub secrets | A JSON object mapping environment variable names to secret values, exposed to the module test jobs. | No | +| `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 | -#### Passing secrets and variables to the tests +#### Passing test data (secrets and variables) to the tests -`TestSecrets` (a secret) and `TestVariables` (a regular input) let 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. Use `TestSecrets` for sensitive values -(masked in the logs) and `TestVariables` for non-secret configuration such as URLs, usernames and -identifiers (not masked). The calling workflow decides exactly what is passed by building single-line -JSON objects. Use `toJSON(secrets.)` / `toJSON(vars.)` so that quoting and multi-line -values (such as private keys) are encoded correctly. A folded `>-` block keeps the source readable -while producing a single-line value: +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: ```yaml jobs: @@ -423,35 +426,35 @@ jobs: uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v5 secrets: APIKey: ${{ secrets.APIKEY }} - TestSecrets: >- - { - "CONFLUENCE_API_TOKEN": ${{ toJSON(secrets.CONFLUENCE_API_TOKEN) }} - } - with: - TestVariables: >- - { - "CONFLUENCE_API_BASE_URI": ${{ toJSON(vars.CONFLUENCE_API_BASE_URI) }}, + 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) }} - } + "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 TestSecrets (masked in logs) -$env:CONFLUENCE_API_BASE_URI # from TestVariables (not masked) +$env:CONFLUENCE_API_TOKEN # from the "secrets" map (masked in logs) +$env:CONFLUENCE_SITE # from the "variables" map (not masked) ``` Notes: - The names are entirely caller-defined; no secret or variable names are hard-coded in the shared workflow. -- `TestSecrets` values are masked in the logs (`::add-mask::`); `TestVariables` values are not masked. -- Provide each object as a single-line value (the folded `>-` block above does this). Avoid a literal +- 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. +- 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. -- Omit `TestSecrets` / `TestVariables` entirely when the module needs no secrets / no non-secret config. +- 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 and repository secrets and variables are supported. Secrets stored in a GitHub *Environment* are not exposed by this mechanism. diff --git a/tests/srcTestRepo/tests/Environment.Tests.ps1 b/tests/srcTestRepo/tests/Environment.Tests.ps1 index ea030fbf..bc939ebc 100644 --- a/tests/srcTestRepo/tests/Environment.Tests.ps1 +++ b/tests/srcTestRepo/tests/Environment.Tests.ps1 @@ -1,8 +1,8 @@ -Describe 'TestSecrets and TestVariables are exposed to the module tests' { - # PSMODULE_TEST_SINGLELINE_SECRET / PSMODULE_TEST_MULTILINE_SECRET (secrets, masked) and - # PSMODULE_TEST_VARIABLE (a non-secret variable, not masked) are dedicated fixtures that exist only - # to prove the TestSecrets/TestVariables plumbing. The calling workflow passes them through and the - # framework exposes them as environment variables; these tests confirm they arrive correct. +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' @@ -12,23 +12,7 @@ $actual.Length | Should -Be 50 } - It 'Exposes the multi-line secret with every line intact' { - $expected = @( - 'psmodule-public-nonsecret-test-fixture-line-1' - 'psmodule-public-nonsecret-test-fixture-line-2' - 'psmodule-public-nonsecret-test-fixture-line-3' - ) -join "`n" - $actual = [System.Environment]::GetEnvironmentVariable('PSMODULE_TEST_MULTILINE_SECRET') - $actual | Should -Not -BeNullOrEmpty - $lines = $actual -split "`r?`n" - $lines.Count | Should -Be 3 - $lines[0] | Should -BeExactly 'psmodule-public-nonsecret-test-fixture-line-1' - $lines[2] | Should -BeExactly 'psmodule-public-nonsecret-test-fixture-line-3' - ($actual -replace "`r`n", "`n") | Should -BeExactly $expected - ($actual -replace "`r`n", "`n").Length | Should -Be 137 - } - - It 'Exposes a non-secret variable via TestVariables' { + 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' diff --git a/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 b/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 index ea030fbf..bc939ebc 100644 --- a/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 +++ b/tests/srcWithManifestTestRepo/tests/Environments/Environment.Tests.ps1 @@ -1,8 +1,8 @@ -Describe 'TestSecrets and TestVariables are exposed to the module tests' { - # PSMODULE_TEST_SINGLELINE_SECRET / PSMODULE_TEST_MULTILINE_SECRET (secrets, masked) and - # PSMODULE_TEST_VARIABLE (a non-secret variable, not masked) are dedicated fixtures that exist only - # to prove the TestSecrets/TestVariables plumbing. The calling workflow passes them through and the - # framework exposes them as environment variables; these tests confirm they arrive correct. +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' @@ -12,23 +12,7 @@ $actual.Length | Should -Be 50 } - It 'Exposes the multi-line secret with every line intact' { - $expected = @( - 'psmodule-public-nonsecret-test-fixture-line-1' - 'psmodule-public-nonsecret-test-fixture-line-2' - 'psmodule-public-nonsecret-test-fixture-line-3' - ) -join "`n" - $actual = [System.Environment]::GetEnvironmentVariable('PSMODULE_TEST_MULTILINE_SECRET') - $actual | Should -Not -BeNullOrEmpty - $lines = $actual -split "`r?`n" - $lines.Count | Should -Be 3 - $lines[0] | Should -BeExactly 'psmodule-public-nonsecret-test-fixture-line-1' - $lines[2] | Should -BeExactly 'psmodule-public-nonsecret-test-fixture-line-3' - ($actual -replace "`r`n", "`n") | Should -BeExactly $expected - ($actual -replace "`r`n", "`n").Length | Should -Be 137 - } - - It 'Exposes a non-secret variable via TestVariables' { + 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' From 06d6d8591a059030a091b651365e8a6cbbf58b6e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 14:08:15 +0200 Subject: [PATCH 08/11] Document safe TestData formatting --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f7072041..be38c693 100644 --- a/README.md +++ b/README.md @@ -418,7 +418,8 @@ workflow. It is one JSON object with two maps, so everything the tests need is v 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: +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: @@ -450,9 +451,16 @@ Notes: 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. From 39864d11aab5b496cb0565538741c0390eccaeb1 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 14:56:43 +0200 Subject: [PATCH 09/11] Document TestData migration path --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index be38c693..81a30722 100644 --- a/README.md +++ b/README.md @@ -405,6 +405,33 @@ credentials that are exposed. `secrets: inherit` is intentionally not 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 From cdfd9ae7e7c653def2f882e4c2a130cee7d6b830 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 15:09:56 +0200 Subject: [PATCH 10/11] Harden TestData parsing per review --- .github/workflows/AfterAll-ModuleLocal.yml | 74 +++++++++++++++++++-- .github/workflows/BeforeAll-ModuleLocal.yml | 74 +++++++++++++++++++-- .github/workflows/Test-ModuleLocal.yml | 74 +++++++++++++++++++-- README.md | 20 +++--- 4 files changed, 213 insertions(+), 29 deletions(-) diff --git a/.github/workflows/AfterAll-ModuleLocal.yml b/.github/workflows/AfterAll-ModuleLocal.yml index 5cc0edf1..e16dc5d1 100644 --- a/.github/workflows/AfterAll-ModuleLocal.yml +++ b/.github/workflows/AfterAll-ModuleLocal.yml @@ -43,17 +43,68 @@ jobs: try { $data = $env:PSMODULE_TEST_DATA | ConvertFrom-Json -ErrorAction Stop } catch { - throw "The 'TestData' secret must be a JSON object with 'secrets' and/or 'variables' maps. $_" + 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 { + 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 { + 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 { + 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 { param( [object] $Map, + [string] $Name, [switch] $Mask ) - if (-not $Map) { return } + Assert-Map -Map $Map -Name $Name + if ($null -eq $Map) { return } + $count = 0 foreach ($item in $Map.PSObject.Properties) { $name = $item.Name - $value = [string]$item.Value + Assert-EnvironmentName -Name $name + $value = Get-EnvironmentValue -Value $item.Value -Name $Name if ($Mask) { foreach ($line in ($value -split "`n")) { $line = $line.TrimEnd("`r") @@ -62,16 +113,25 @@ jobs: } } } - $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + do { + $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + } while ($value.Contains($delimiter)) Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" Add-Content -Path $env:GITHUB_ENV -Value $value Add-Content -Path $env:GITHUB_ENV -Value $delimiter - Write-Host "Exposed [$name] as an environment variable." + $count++ + } + if ($count -gt 0) { + if ($Mask) { + Write-Host "Exposed $count secret value(s) as environment variables." + } else { + Write-Host "Exposed $count variable value(s) as environment variables." + } } } - Add-EnvFromMap -Map $data.secrets -Mask - Add-EnvFromMap -Map $data.variables + Add-EnvFromMap -Map $data.secrets -Name 'secrets' -Mask + Add-EnvFromMap -Map $data.variables -Name 'variables' - name: Run AfterAll Teardown Scripts if: always() diff --git a/.github/workflows/BeforeAll-ModuleLocal.yml b/.github/workflows/BeforeAll-ModuleLocal.yml index 067431e6..8cd964a5 100644 --- a/.github/workflows/BeforeAll-ModuleLocal.yml +++ b/.github/workflows/BeforeAll-ModuleLocal.yml @@ -43,17 +43,68 @@ jobs: try { $data = $env:PSMODULE_TEST_DATA | ConvertFrom-Json -ErrorAction Stop } catch { - throw "The 'TestData' secret must be a JSON object with 'secrets' and/or 'variables' maps. $_" + 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 { + 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 { + 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 { + 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 { param( [object] $Map, + [string] $Name, [switch] $Mask ) - if (-not $Map) { return } + Assert-Map -Map $Map -Name $Name + if ($null -eq $Map) { return } + $count = 0 foreach ($item in $Map.PSObject.Properties) { $name = $item.Name - $value = [string]$item.Value + Assert-EnvironmentName -Name $name + $value = Get-EnvironmentValue -Value $item.Value -Name $Name if ($Mask) { foreach ($line in ($value -split "`n")) { $line = $line.TrimEnd("`r") @@ -62,16 +113,25 @@ jobs: } } } - $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + do { + $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + } while ($value.Contains($delimiter)) Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" Add-Content -Path $env:GITHUB_ENV -Value $value Add-Content -Path $env:GITHUB_ENV -Value $delimiter - Write-Host "Exposed [$name] as an environment variable." + $count++ + } + if ($count -gt 0) { + if ($Mask) { + Write-Host "Exposed $count secret value(s) as environment variables." + } else { + Write-Host "Exposed $count variable value(s) as environment variables." + } } } - Add-EnvFromMap -Map $data.secrets -Mask - Add-EnvFromMap -Map $data.variables + Add-EnvFromMap -Map $data.secrets -Name 'secrets' -Mask + Add-EnvFromMap -Map $data.variables -Name 'variables' - name: Run BeforeAll Setup Scripts uses: PSModule/GitHub-Script@1ee97bbc652d19c38ae12f6e1e47e9d9fbd12d0a # v1.8.0 diff --git a/.github/workflows/Test-ModuleLocal.yml b/.github/workflows/Test-ModuleLocal.yml index 0ddcc35e..4f54093b 100644 --- a/.github/workflows/Test-ModuleLocal.yml +++ b/.github/workflows/Test-ModuleLocal.yml @@ -48,17 +48,68 @@ jobs: try { $data = $env:PSMODULE_TEST_DATA | ConvertFrom-Json -ErrorAction Stop } catch { - throw "The 'TestData' secret must be a JSON object with 'secrets' and/or 'variables' maps. $_" + 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 { + 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 { + 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 { + 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 { param( [object] $Map, + [string] $Name, [switch] $Mask ) - if (-not $Map) { return } + Assert-Map -Map $Map -Name $Name + if ($null -eq $Map) { return } + $count = 0 foreach ($item in $Map.PSObject.Properties) { $name = $item.Name - $value = [string]$item.Value + Assert-EnvironmentName -Name $name + $value = Get-EnvironmentValue -Value $item.Value -Name $Name if ($Mask) { foreach ($line in ($value -split "`n")) { $line = $line.TrimEnd("`r") @@ -67,16 +118,25 @@ jobs: } } } - $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + do { + $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + } while ($value.Contains($delimiter)) Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" Add-Content -Path $env:GITHUB_ENV -Value $value Add-Content -Path $env:GITHUB_ENV -Value $delimiter - Write-Host "Exposed [$name] as an environment variable." + $count++ + } + if ($count -gt 0) { + if ($Mask) { + Write-Host "Exposed $count secret value(s) as environment variables." + } else { + Write-Host "Exposed $count variable value(s) as environment variables." + } } } - Add-EnvFromMap -Map $data.secrets -Mask - Add-EnvFromMap -Map $data.variables + Add-EnvFromMap -Map $data.secrets -Name 'secrets' -Mask + Add-EnvFromMap -Map $data.variables -Name 'variables' - name: Download module artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/README.md b/README.md index 81a30722..4857741e 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,8 +397,9 @@ jobs: ### Secrets -The workflow declares only two secrets, which keeps the calling workflow in full control of the -credentials that are exposed. `secrets: inherit` is intentionally not required. +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 | | ---- | -------- | ----------- | -------- | @@ -426,7 +427,7 @@ jobs: Process-PSModule: uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v5 secrets: - APIKey: ${{ secrets.APIKEY }} + APIKey: ${{ secrets.APIKey }} TestData: >- { "secrets": { "TEST_USER_PAT": "${{ secrets.TEST_USER_PAT }}", "TEST_APP_ORG_CLIENT_ID": "${{ secrets.TEST_APP_ORG_CLIENT_ID }}" } } @@ -453,7 +454,7 @@ jobs: Process-PSModule: uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v5 secrets: - APIKey: ${{ secrets.APIKEY }} + APIKey: ${{ secrets.APIKey }} TestData: >- { "secrets": { "CONFLUENCE_API_TOKEN": "${{ secrets.CONFLUENCE_API_TOKEN }}" }, "variables": { "CONFLUENCE_SITE": ${{ toJSON(vars.CONFLUENCE_SITE) }}, @@ -471,7 +472,9 @@ $env:CONFLUENCE_SITE # from the "variables" map (not masked) Notes: -- The names are entirely caller-defined; no secret or variable names are hard-coded in the shared workflow. +- 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_*`. - 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 @@ -491,8 +494,9 @@ Notes: - 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 and repository secrets and variables are supported. Secrets stored in a GitHub - *Environment* are not exposed by this mechanism. +- 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 From 4dc521f2c46aa76ebedfc467cfd7d1a551b126f3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 15:19:15 +0200 Subject: [PATCH 11/11] Extract shared TestData exposure helper --- .github/scripts/Expose-TestData.ps1 | 138 ++++++++++++++++++ .github/workflows/AfterAll-ModuleLocal.yml | 97 +----------- .github/workflows/BeforeAll-ModuleLocal.yml | 97 +----------- .github/workflows/Test-Module.yml | 31 ---- .github/workflows/Test-ModuleLocal.yml | 97 +----------- .github/workflows/Workflow-Test-Default.yml | 1 + .../workflows/Workflow-Test-WithManifest.yml | 1 + README.md | 2 + 8 files changed, 145 insertions(+), 319 deletions(-) create mode 100644 .github/scripts/Expose-TestData.ps1 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 e16dc5d1..fd3346ae 100644 --- a/.github/workflows/AfterAll-ModuleLocal.yml +++ b/.github/workflows/AfterAll-ModuleLocal.yml @@ -36,102 +36,7 @@ jobs: env: PSMODULE_TEST_DATA: ${{ secrets.TestData }} run: | - if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_DATA)) { - Write-Host '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 { - 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 { - 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 { - 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 { - 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-Host "::add-mask::$line" - } - } - } - do { - $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" - } while ($value.Contains($delimiter)) - Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" - Add-Content -Path $env:GITHUB_ENV -Value $value - Add-Content -Path $env:GITHUB_ENV -Value $delimiter - $count++ - } - if ($count -gt 0) { - if ($Mask) { - Write-Host "Exposed $count secret value(s) as environment variables." - } else { - Write-Host "Exposed $count variable value(s) as environment variables." - } - } - } - - Add-EnvFromMap -Map $data.secrets -Name 'secrets' -Mask - Add-EnvFromMap -Map $data.variables -Name 'variables' + ./.github/scripts/Expose-TestData.ps1 - name: Run AfterAll Teardown Scripts if: always() diff --git a/.github/workflows/BeforeAll-ModuleLocal.yml b/.github/workflows/BeforeAll-ModuleLocal.yml index 8cd964a5..8b933d98 100644 --- a/.github/workflows/BeforeAll-ModuleLocal.yml +++ b/.github/workflows/BeforeAll-ModuleLocal.yml @@ -36,102 +36,7 @@ jobs: env: PSMODULE_TEST_DATA: ${{ secrets.TestData }} run: | - if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_DATA)) { - Write-Host '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 { - 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 { - 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 { - 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 { - 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-Host "::add-mask::$line" - } - } - } - do { - $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" - } while ($value.Contains($delimiter)) - Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" - Add-Content -Path $env:GITHUB_ENV -Value $value - Add-Content -Path $env:GITHUB_ENV -Value $delimiter - $count++ - } - if ($count -gt 0) { - if ($Mask) { - Write-Host "Exposed $count secret value(s) as environment variables." - } else { - Write-Host "Exposed $count variable value(s) as environment variables." - } - } - } - - Add-EnvFromMap -Map $data.secrets -Name 'secrets' -Mask - Add-EnvFromMap -Map $data.variables -Name 'variables' + ./.github/scripts/Expose-TestData.ps1 - name: Run BeforeAll Setup Scripts uses: PSModule/GitHub-Script@1ee97bbc652d19c38ae12f6e1e47e9d9fbd12d0a # v1.8.0 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 4f54093b..c8e806a2 100644 --- a/.github/workflows/Test-ModuleLocal.yml +++ b/.github/workflows/Test-ModuleLocal.yml @@ -41,102 +41,7 @@ jobs: env: PSMODULE_TEST_DATA: ${{ secrets.TestData }} run: | - if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_DATA)) { - Write-Host '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 { - 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 { - 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 { - 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 { - 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-Host "::add-mask::$line" - } - } - } - do { - $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" - } while ($value.Contains($delimiter)) - Add-Content -Path $env:GITHUB_ENV -Value "$name<<$delimiter" - Add-Content -Path $env:GITHUB_ENV -Value $value - Add-Content -Path $env:GITHUB_ENV -Value $delimiter - $count++ - } - if ($count -gt 0) { - if ($Mask) { - Write-Host "Exposed $count secret value(s) as environment variables." - } else { - Write-Host "Exposed $count variable value(s) as environment variables." - } - } - } - - Add-EnvFromMap -Map $data.secrets -Name 'secrets' -Mask - Add-EnvFromMap -Map $data.variables -Name 'variables' + ./.github/scripts/Expose-TestData.ps1 - name: Download module artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index 7ba93a91..f96c41b3 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -25,6 +25,7 @@ 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 }} diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 3e636b5f..b7242098 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -25,6 +25,7 @@ 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 }} diff --git a/README.md b/README.md index 4857741e..d15b661b 100644 --- a/README.md +++ b/README.md @@ -475,6 +475,8 @@ 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