From 2e53837fe1dd35a1f1c54bccc9eac8899c435233 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 11 Jul 2026 15:42:27 +0200 Subject: [PATCH 1/4] spike: source module test data from the Testing GitHub Environment - Pin the Process-PSModule reusable workflow to spike/environment-support, which adds an optional Environment input to the module test jobs. - Pass Environment: Testing and drop the caller-built TestData blob. A job that calls a reusable workflow cannot set an environment, so the caller cannot read environment-scoped secrets/variables itself; the binding happens inside the reusable workflow jobs. - Environment.Tests.ps1 now asserts TEST_ENV_SECRET / TEST_ENV_VARIABLE, which exist only in the Testing environment (not at repo level), proving the values were sourced from the environment. - Touch src/ so the change matches the ^src/ important-file pattern and the module build + local test jobs run. --- .github/workflows/Process-PSModule.yml | 17 +++++++---------- src/functions/public/Get-PSModuleTest.ps1 | 2 ++ tests/Environment.Tests.ps1 | 23 +++++++++++++---------- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 0aee102..cb91050 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -27,15 +27,12 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@d4020f3c9c3621cebae5d8bf4ffaf10f55c1784a # v6.1.2 + # SPIKE: pinned to the Process-PSModule branch that adds the 'Environment' input. + # Secrets and variables now come from the "Testing" GitHub Environment, not from a + # caller-built TestData blob (a job that calls a reusable workflow cannot set an + # environment, so it cannot read environment-scoped secrets/variables itself). + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@spike/environment-support secrets: APIKey: ${{ secrets.APIKey }} - TestData: >- - { - "secrets": { - "TEST_SECRET": "${{ secrets.TEST_SECRET }}" - }, - "variables": { - "TEST_VARIABLE": "${{ vars.TEST_VARIABLE }}" - } - } + with: + Environment: Testing diff --git a/src/functions/public/Get-PSModuleTest.ps1 b/src/functions/public/Get-PSModuleTest.ps1 index fe4d4cc..78451b3 100644 --- a/src/functions/public/Get-PSModuleTest.ps1 +++ b/src/functions/public/Get-PSModuleTest.ps1 @@ -22,5 +22,7 @@ [Parameter(Mandatory)] [string] $Name ) + # SPIKE: touched so the change matches the ^src/ important-file pattern and the + # module build + local test jobs run for this pull request. Write-Output "Hello, $Name!" } diff --git a/tests/Environment.Tests.ps1 b/tests/Environment.Tests.ps1 index 3b90a6b..039f123 100644 --- a/tests/Environment.Tests.ps1 +++ b/tests/Environment.Tests.ps1 @@ -13,21 +13,24 @@ [CmdletBinding()] param() -Describe 'TestData is pushed into the module tests' { - It 'Exposes the secret from the "secrets" map' { - $actual = [System.Environment]::GetEnvironmentVariable('TEST_SECRET') +Describe 'Environment-scoped test data reaches the module tests' { + # TEST_ENV_SECRET / TEST_ENV_VARIABLE exist ONLY in the "Testing" GitHub Environment + # (not at repository level), so seeing them here proves the reusable workflow sourced + # them from the environment binding rather than from a caller-provided TestData blob. + It 'Exposes the environment secret as $env:TEST_ENV_SECRET' { + $actual = [System.Environment]::GetEnvironmentVariable('TEST_ENV_SECRET') $actual | Should -Not -BeNullOrEmpty - $actual | Should -BeExactly 'mariustestmodule-secret-fixture-value' + $actual | Should -BeExactly 'env-scoped-secret-fixture-value' } - It 'Exposes the variable from the "variables" map' { - $actual = [System.Environment]::GetEnvironmentVariable('TEST_VARIABLE') + It 'Exposes the environment variable as $env:TEST_ENV_VARIABLE' { + $actual = [System.Environment]::GetEnvironmentVariable('TEST_ENV_VARIABLE') $actual | Should -Not -BeNullOrEmpty - $actual | Should -BeExactly 'mariustestmodule-variable-fixture-value' + $actual | Should -BeExactly 'env-scoped-variable-fixture-value' } - It 'Masks the secret in the log even when a test prints it in plain text' { - Write-Host "Secret in plain text: $env:TEST_SECRET" - Write-Host "Variable in plain text: $env:TEST_VARIABLE" + It 'Masks the environment secret in the log even when a test prints it in plain text' { + Write-Host "Secret in plain text: $env:TEST_ENV_SECRET" + Write-Host "Variable in plain text: $env:TEST_ENV_VARIABLE" } } From 5d95b43207ed23c6f619d2c34108984cf3cbcf13 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 11 Jul 2026 15:49:51 +0200 Subject: [PATCH 2/4] spike: add env-probe workflows to isolate environment secret/variable access in direct vs reusable jobs --- .github/workflows/env-probe-reusable.yml | 35 ++++++++++++++++++ .github/workflows/env-probe.yml | 47 ++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 .github/workflows/env-probe-reusable.yml create mode 100644 .github/workflows/env-probe.yml diff --git a/.github/workflows/env-probe-reusable.yml b/.github/workflows/env-probe-reusable.yml new file mode 100644 index 0000000..0b9641a --- /dev/null +++ b/.github/workflows/env-probe-reusable.yml @@ -0,0 +1,35 @@ +name: env-probe-reusable + +# SPIKE probe: a reusable workflow whose job binds to the `Testing` environment. +# Determines whether an environment SECRET is reachable inside a reusable workflow, +# both by enumeration (toJSON(secrets)) and by explicit declared name. + +on: + workflow_call: + secrets: + TEST_ENV_SECRET: + description: Declared so it can be referenced by name; the environment value should override. + required: false + +permissions: + contents: read + +jobs: + reusable-probe: + runs-on: ubuntu-latest + environment: Testing + steps: + - name: Probe contexts inside a reusable workflow bound to the environment + shell: pwsh + env: + ALL_SECRETS: ${{ toJSON(secrets) }} + ALL_VARS: ${{ toJSON(vars) }} + NAMED_DECLARED_SECRET: ${{ secrets.TEST_ENV_SECRET }} + NAMED_ENV_VAR: ${{ vars.TEST_ENV_VARIABLE }} + run: | + $s = $env:ALL_SECRETS | ConvertFrom-Json + $v = $env:ALL_VARS | ConvertFrom-Json + Write-Host "REUSABLE secrets keys: $((@($s.PSObject.Properties.Name) | Sort-Object) -join ', ')" + Write-Host "REUSABLE vars keys: $((@($v.PSObject.Properties.Name) | Sort-Object) -join ', ')" + Write-Host "REUSABLE named declared secret (TEST_ENV_SECRET) present: $(-not [string]::IsNullOrEmpty($env:NAMED_DECLARED_SECRET))" + Write-Host "REUSABLE named env var (TEST_ENV_VARIABLE) present: $(-not [string]::IsNullOrEmpty($env:NAMED_ENV_VAR))" diff --git a/.github/workflows/env-probe.yml b/.github/workflows/env-probe.yml new file mode 100644 index 0000000..972841f --- /dev/null +++ b/.github/workflows/env-probe.yml @@ -0,0 +1,47 @@ +name: env-probe + +# SPIKE probe: compares how an environment's secret/variable are seen by a DIRECT +# job vs a REUSABLE workflow job, both bound to the `Testing` environment. +# Runs on push to the spike branch (workflow_dispatch needs the file on the default branch). + +on: + push: + branches: + - spike/environment-testdata + workflow_dispatch: + +permissions: + contents: read + +jobs: + # Baseline: a normal (non-reusable) job binds to an environment directly. + direct-probe: + runs-on: ubuntu-latest + environment: Testing + steps: + - name: Probe contexts in a direct job bound to the environment + shell: pwsh + env: + ALL_SECRETS: ${{ toJSON(secrets) }} + ALL_VARS: ${{ toJSON(vars) }} + NAMED_SECRET: ${{ secrets.TEST_ENV_SECRET }} + NAMED_VAR: ${{ vars.TEST_ENV_VARIABLE }} + run: | + $s = $env:ALL_SECRETS | ConvertFrom-Json + $v = $env:ALL_VARS | ConvertFrom-Json + Write-Host "DIRECT secrets keys: $((@($s.PSObject.Properties.Name) | Sort-Object) -join ', ')" + Write-Host "DIRECT vars keys: $((@($v.PSObject.Properties.Name) | Sort-Object) -join ', ')" + Write-Host "DIRECT named env secret present: $(-not [string]::IsNullOrEmpty($env:NAMED_SECRET))" + Write-Host "DIRECT named env var present: $(-not [string]::IsNullOrEmpty($env:NAMED_VAR))" + + # The real question: does a reusable workflow bound to the environment get the env + # secret WITHOUT the caller passing it (the caller cannot, being a `uses:` job)? + reusable-no-pass: + uses: ./.github/workflows/env-probe-reusable.yml + # deliberately passes nothing + + # Does secrets: inherit change anything? (The caller job cannot bind to an + # environment, so inherited secrets should NOT include the environment secret.) + reusable-inherit: + uses: ./.github/workflows/env-probe-reusable.yml + secrets: inherit From 641578e236f023814e5ff418c8d14890bb97edab Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 11 Jul 2026 15:53:53 +0200 Subject: [PATCH 3/4] spike: caller uses secrets: inherit so the Testing environment secret flows through --- .github/workflows/Process-PSModule.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index cb91050..67cf8df 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -28,11 +28,11 @@ permissions: jobs: Process-PSModule: # SPIKE: pinned to the Process-PSModule branch that adds the 'Environment' input. - # Secrets and variables now come from the "Testing" GitHub Environment, not from a - # caller-built TestData blob (a job that calls a reusable workflow cannot set an - # environment, so it cannot read environment-scoped secrets/variables itself). + # Environment secrets are only reachable inside the reusable workflow when the whole + # call chain uses `secrets: inherit` AND the test jobs bind to the environment. A job + # that calls a reusable workflow cannot set an environment, so the binding happens + # inside Process-PSModule; the caller just inherits secrets and names the environment. uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@spike/environment-support - secrets: - APIKey: ${{ secrets.APIKey }} + secrets: inherit with: Environment: Testing From d7147b3d43341cee0c6cc17bb6c95ad24b5b7b4e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 11 Jul 2026 16:00:31 +0200 Subject: [PATCH 4/4] spike: revert caller to explicit APIKey (cross-owner), add 3-level nested probe Cross-owner (personal -> org) cannot use secrets: inherit, so pass APIKey explicitly and prove environment VARIABLES end-to-end; skip the env secret assertion with a cross-owner note. Add env-probe-mid.yml for a 3-level nested inherit + env-binding probe mirroring caller -> workflow.yml -> Test-ModuleLocal. --- .github/workflows/Process-PSModule.yml | 12 +++++++----- .github/workflows/env-probe-mid.yml | 16 +++++++++++++++ .github/workflows/env-probe.yml | 6 ++++++ tests/Environment.Tests.ps1 | 27 +++++++++++++++----------- 4 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/env-probe-mid.yml diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 67cf8df..9c157c3 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -28,11 +28,13 @@ permissions: jobs: Process-PSModule: # SPIKE: pinned to the Process-PSModule branch that adds the 'Environment' input. - # Environment secrets are only reachable inside the reusable workflow when the whole - # call chain uses `secrets: inherit` AND the test jobs bind to the environment. A job - # that calls a reusable workflow cannot set an environment, so the binding happens - # inside Process-PSModule; the caller just inherits secrets and names the environment. + # MariusStorhaug (personal) -> PSModule (org) is CROSS-OWNER, and `secrets: inherit` + # only works within the same org/enterprise, so we must pass APIKey explicitly. That + # closes the secrets context, so environment SECRETS cannot flow here (same-org + # consumers using secrets: inherit do get them). Environment VARIABLES still flow via + # the vars context once the test jobs bind to the environment. uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@spike/environment-support - secrets: inherit + secrets: + APIKey: ${{ secrets.APIKey }} with: Environment: Testing diff --git a/.github/workflows/env-probe-mid.yml b/.github/workflows/env-probe-mid.yml new file mode 100644 index 0000000..78a94bb --- /dev/null +++ b/.github/workflows/env-probe-mid.yml @@ -0,0 +1,16 @@ +name: env-probe-mid + +# SPIKE probe: intermediate reusable workflow that does NOT bind an environment; it +# only forwards secrets down with `secrets: inherit`. Mirrors Process-PSModule's +# workflow.yml sitting between the caller and the environment-bound leaf job. + +on: + workflow_call: + +permissions: + contents: read + +jobs: + mid: + uses: ./.github/workflows/env-probe-reusable.yml + secrets: inherit diff --git a/.github/workflows/env-probe.yml b/.github/workflows/env-probe.yml index 972841f..a29319a 100644 --- a/.github/workflows/env-probe.yml +++ b/.github/workflows/env-probe.yml @@ -45,3 +45,9 @@ jobs: reusable-inherit: uses: ./.github/workflows/env-probe-reusable.yml secrets: inherit + + # 3-level nested chain mirroring Process-PSModule (caller -> workflow.yml -> + # Test-ModuleLocal): inherit at every hop, environment bound only on the leaf. + reusable-3level: + uses: ./.github/workflows/env-probe-mid.yml + secrets: inherit diff --git a/tests/Environment.Tests.ps1 b/tests/Environment.Tests.ps1 index 039f123..164d8ab 100644 --- a/tests/Environment.Tests.ps1 +++ b/tests/Environment.Tests.ps1 @@ -14,23 +14,28 @@ param() Describe 'Environment-scoped test data reaches the module tests' { - # TEST_ENV_SECRET / TEST_ENV_VARIABLE exist ONLY in the "Testing" GitHub Environment - # (not at repository level), so seeing them here proves the reusable workflow sourced - # them from the environment binding rather than from a caller-provided TestData blob. - It 'Exposes the environment secret as $env:TEST_ENV_SECRET' { - $actual = [System.Environment]::GetEnvironmentVariable('TEST_ENV_SECRET') - $actual | Should -Not -BeNullOrEmpty - $actual | Should -BeExactly 'env-scoped-secret-fixture-value' - } - + # This module is called CROSS-OWNER (MariusStorhaug personal account -> PSModule org). + # GitHub only allows `secrets: inherit` within the same org/enterprise, so environment + # SECRETS cannot flow to a cross-owner consumer. Environment VARIABLES flow regardless, + # via the vars context, once the reusable workflow binds the test job to the environment. + # TEST_ENV_VARIABLE exists ONLY in the "Testing" environment (not at repo level), so + # seeing it here proves it was sourced from the environment. It 'Exposes the environment variable as $env:TEST_ENV_VARIABLE' { $actual = [System.Environment]::GetEnvironmentVariable('TEST_ENV_VARIABLE') $actual | Should -Not -BeNullOrEmpty $actual | Should -BeExactly 'env-scoped-variable-fixture-value' } - It 'Masks the environment secret in the log even when a test prints it in plain text' { - Write-Host "Secret in plain text: $env:TEST_ENV_SECRET" + # Environment secrets require `secrets: inherit` end-to-end, which is unavailable + # cross-owner. A SAME-ORG consumer would drop the -Skip and this would pass (proven by + # the env-probe* workflows in this repo, which show the secret reaching a reusable + # workflow that is called with secrets: inherit and bound to the environment). + It 'Exposes the environment secret as $env:TEST_ENV_SECRET (same-org callers only)' -Skip { + $actual = [System.Environment]::GetEnvironmentVariable('TEST_ENV_SECRET') + $actual | Should -BeExactly 'env-scoped-secret-fixture-value' + } + + It 'Shows the environment variable in the log' { Write-Host "Variable in plain text: $env:TEST_ENV_VARIABLE" } }