This is a script that can be used in GitHub Actions, when running a Workflow job with Steps that use PowerShell as the shell and therefore execute PowerShell code.
It allows the user to define variables in any step and be able to access them in all subsequent steps, and do so transparently without using GitHub Actions placeholders. As if the code from all the steps is running in a single script, without having to define the variables on each step.
The result is that this approach streamlines the process of passing data between different steps, when running PowerShell, which simplifies the workflow job and reduces the boilerplate code significantly from the yaml file.
First the setup:
- I have a self-hosted runner that runs Windows Server 2025.
- I've copied this script locally on the self-hosted runner VM
- I have a workflow with a job which has steps that run PowerShell 7.
- I need to pass data between those steps (a very common thing indeed)
The way this script is used, is through the shell property in the GitHub Actions yaml file.
The script works as a wrapper for the user's code on each step. It executes a few lines before and after the user's step.
The way this works is that it utilizes PowerShell Remoting to keep a persistent PowerShell session, throughout all the workflow steps. So when the job starts, it creates a new PSSesion and then on each subsequent step it connects to that PS Session.
What the PowerShell remoting does, is it essentially spins up a new PowerShell process locally. On that process we run the user's code, and so it retains its state which are essentially the variables and modules or functions loaded in the session.
Even though each step of the workflow job is an individual PowerShell process itself, the process from PS Remoting is separate from the step process, and as such it is kept alive throughout all the steps and that's essentially how we manage to keep the state.
This is a comparison between the regular way that GitHub Actions defines variables and then uses them on the next steps.
Against this simplified way where we don't need to use any placeholders to refer to those variables.
The example consists of two steps where on the first step we define some state and on the second step we access it.
The main difference is that on the regular way everytime we define some state (variables,functions,env vars), we have to save it as GitHub Actions variable. And then on all subsequent steps, we need to restore them using placeholders ( typically through $ {{ .. }} ) and only then we can access them.
Finally, in most cases in PowerShell we tend to have variables that are objects or even array of objects, instead of just simple strings. And so to be able to pass them onto the next steps, we also need to serialize them in some way, which is an extra overhead to the process. (I opted for JSON format here in the example, but you can also use CLI XML)
Also another important difference is between the two shell properties, where on the 2nd one we use our script as part of the shell command which wraps the user's code on each step. Do note that the default "pwsh" shell in a typical workflow, actually means pwsh -command ". '{0}'" according to the docs
defaults:
run:
shell: pwsh
jobs:
sample_steps:
runs-on: [SomeRunner]
steps:
- name: Add items to the state
id: step1
run : |
# define the state items
$services = Get-Service WinRM,Power
$env:admins = (Get-LocalGroupMember -Group Administrators).Name -join ','
function Get-MyFunc {param($Name) Write-Output "The name is: $Name"}
Import-Module Microsoft.PowerShell.ThreadJob
# save the state items to GitHub Actoins variables so the next steps can access them
"services=$($services | ConvertTo-Json -Compress)" | Out-File -Path $env:GITHUB_OUTPUT -Append
"admins=$env:admins" | Out-File -Path $env:GITHUB_ENV -Append
"func=$((Get-Command Get-MyFunc).Definition)" | Out-File -Path $env:GITHUB_OUTPUT -Append
"mod=Microsoft.PowerShell.ThreadJob" | Out-File -Path $env:GITHUB_OUTPUT -Append
- name: Check the state
id: step2
env:
services: ${{ steps.step1.outputs.services }}
run : |
# restore the state items from the GitHub Actions variables so we can use them on this step
$services = $env:services | ConvertFrom-Json
Import-Module '${{ steps.step1.outputs.mod }}'
Set-Item -Path Function:\Get-MyFunc -Value '${{ steps.step1.outputs.func }}'
# refer to the state items
if ($services) {$services | Out-String}
if (Test-Path env:admins) {$env:admins}
Get-Module | where Name -like *ThreadJob | Out-String
if (Test-Path Function:\Get-MyFunc) {Get-MyFunc test}
defaults:
run:
shell: pwsh -command "C:/Temp/StepWrapper.ps1 '{0}'"
jobs:
sample_steps:
runs-on: [SomeRunner]
steps:
- name: Add items to the state
run : |
# define the state items
$services = Get-Service WinRM,Power
$env:admins = (Get-LocalGroupMember -Group Administrators).Name -join ','
function Get-MyFunc {param($Name) Write-Output "The name is: $Name"}
Import-Module Microsoft.PowerShell.ThreadJob
- name: Check the state
run : |
# refer to the state items
if ($services) {$services | Out-String}
if (Test-Path env:admins) {$env:admins}
Get-Module | where Name -like *ThreadJob | Out-String
if (Test-Path Function:\Get-MyFunc) {Get-MyFunc test}
GitHub Actions Workflow Steps
Step #1 Step #2 Step N Step Last
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐
│ Create │ │ Connect │ │ Connect │ │ Connect │
│ Session │ │ Session │ │ Session │ │ Session │
│ ↓↓ │ ==> │ ↓↓ │ ... │ ↓↓ │ ==> │ ↓↓ │
│ do-stuff │ ==> │ do-stuff │ ... │ do-stuff │ ==> │ do-stuff │
│ ↓↓ │ │ ↓↓ │ │ ↓↓ │ │ ↓↓ │
│Disconnect│ │Disconnect│ │Disconnect│ │ Remove │
│ Session │ │ Session │ │ Session │ │ Session │
└──────────┘ └──────────┘ └──────────┘ └───────────┘
This method uses PowerShell remoting through WinRM (not through SSH). Which means if the code in any step includes any PS Remoting itself (ie if the runner tries to Invoke-Command), then that will error out.
This is because of the double-hop limitation in remoting. (there are workarounds but they are not the scope of this method).
If you need to do PS Remoting as part of your steps, then use the alternative option (through the RestoreSession module) that uses a local file to keep the user's state.
Requirements:
- You must copy this script on the GitHub Actions runner, for ex. in C:\Temp [Why: so the shell property can refer to it]
- This only works on Windows runners (not Linux) [Why: because WinRM is windows-only]
- You must enable PowerShell Remoting on the runner. [How: Enable-PSRemoting]
- This only works with PowerShell shell (not Bash)
Improvement:
- We could make this Linux-compatible, if we use SSH for PS Remoting instead of WinRM. That would mean, that you'll need to install SSH and configure it accordingly to use PowerShell.
- Also you'll need to change this script to accomodate for the SSH remoting. And most likely configure the appropriate SSH Keys for passwordless authentication.
Disclaimer:
I have not used AI for this gist (for better or for worse). This means no AI was used to write any of the files or the code here. The markdown in this readme, the PowerShell code in the .ps1 or the GitHub Actions Workflow in the .yml, all were written by hand.
The initial motive for this work came in from Adam Driscoll's restore module. But after some research the technique through a remote runspace was a simpler solution. Plus the data serialization upon every AvailabilityChange() event of the runspace, was heavy on the system.
This gist includes:
- This readme.md file
- A screenshot of a sample GitHub Actions run (.jpg)
- The sample GitHub Actions workflow file (.yml)
- The PowerShell script that is used on the shell property (StepWrapper.ps1)
Metadata:
- Author: Panos Grigoriadis
- Date: 23-Feb-2026
- Tags: PowerShell, GitHub Actions, variables, state
- Gist: https://gist.github.com/PanosGreg/673784c2565cb1e798ff4ff259deee15
