Skip to content

Instantly share code, notes, and snippets.

@PanosGreg
Last active February 23, 2026 15:35
Show Gist options
  • Select an option

  • Save PanosGreg/673784c2565cb1e798ff4ff259deee15 to your computer and use it in GitHub Desktop.

Select an option

Save PanosGreg/673784c2565cb1e798ff4ff259deee15 to your computer and use it in GitHub Desktop.
GitHub Actions Step Wrapper script for transparent data access between steps

Overview

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.

The setup

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 implementation

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.

Regular VS Simplified way

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

a) Regular way

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}

b) Simplified way

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}

Script Workflow

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  │   
└──────────┘     └──────────┘     └──────────┘     └───────────┘   

Remarks

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.

Notes

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
name: Keep Session
on:
workflow_dispatch:
defaults:
run:
shell: pwsh -command "C:/Temp/StepWrapper.ps1 '{0}'"
jobs:
keep_session:
runs-on: [GhaRunnerVM]
steps:
- name: Add items to the state
run : |
function Write-ColorLog {param(
[Parameter(Position=0)][ValidateSet('Grn','Blu','Yel','Mag','Ora','Red')][string]$Color='Blu',
[Parameter(Position=1,Mandatory,ValueFromPipeline)][AllowEmptyString()][string]$Message)
Begin {$Col = @{Blu = '{0}[38;2;{1};{2};{3}m' -f [char]27, 61, 148, 243 # Blue
Grn = '{0}[38;2;{1};{2};{3}m' -f [char]27, 146, 208, 80 # Green
Ora = '{0}[38;2;{1};{2};{3}m' -f [char]27, 255, 126, 0 # Orange
Mag = '{0}[38;2;{1};{2};{3}m' -f [char]27, 254, 140, 255 # Magenta
Yel = '{0}[38;2;{1};{2};{3}m' -f [char]27, 240, 230, 140 # Yellow
Red = '{0}[38;2;{1};{2};{3}m' -f [char]27, 231, 72, 86}} # Red
Process {'{0}{1}{2}' -f $Col[$Color],$Message,$PSStyle.Reset} End{}}
$svc = Get-Service WinRM,Power
$dir = Get-Item $env:ProgramFiles,$env:ProgramData -Force
$env:admins = (Get-LocalGroupMember -Group Administrators).Name -join ','
'That was the 1st step'
- name: Check the state
run : |
if (gcm Write-ColorLog) {gcm Write-ColorLog | Out-String -Stream | Write-ColorLog Blu}
if ($svc) {$svc | Out-String -Stream | Write-ColorLog Grn}
if ($dir) {$dir | Out-String -Stream | Write-ColorLog Yel}
Get-Module | where Name -like *Accounts | Out-String -Stream | Write-ColorLog Ora
if (Test-Path env:\admins) {$env:admins | Write-ColorLog Mag}
'This is the end...'
<#
.SYNOPSIS
Step-Wrapper script for GitHub Actions Workflows
The main benefit is that it allows any variables defined in a step,
to be accessible to all subsequent steps. As if the code from all the steps
is running as a single script, without having to define the variables on each step.
.DESCRIPTION
This script wraps the user's command on each step.
It provides the functionality to utilize an external PowerShell process
so that the user's state remains persistent across all the Workflow steps
This allows any variables,functions,modules or env variables that are defined
in any step, to be accessible to all subsequent steps.
This streamlines the process of passing data between different steps,
in a GitHub Actions Workflow when running PowerShell.
The result is that it simplifies the workflow job and
reduces the boilerplate code significantly from the yaml file.
How to use this script:
Configure the shell property of the workflow like so:
defaults:
run:
shell: pwsh -command "C:/Temp/StepWrapper.ps1 '{0}'"
Remarks:
This method uses PowerShell remoting through WinRM (not through SSH)
Which means if the code in any step includes any PS Remoting itself, 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)
Note:
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 of course configure the appropriate SSH Keys for passwordless authentication.
.NOTES
Author: Panos Grigoriadis
Date: 22-Feb-2026
#>
param (
[Parameter(Mandatory,Position=0,ValueFromPipeline)]
[ValidateScript({Test-Path $_})] # <-- make sure the file exists (I could even do more checks here)
[string]$File # <-- the user's script for the step
)
#region Helper Functions
function Test-IsPSRemotingEnabled {
[OutputType([bool])]
param (
[switch]$ExitIfFalse
)
Import-Module Microsoft.WSMan.Management # <-- it's needed for the WSMan:\ PSDrive
# check if the service is running
$IsServiceRunning = (Get-Service WinRM).Status -eq 'Running'
# check if the listener is enabled
$IsListenerEnabled = [bool]::Parse((Get-Item WSMan:\localhost\Listener\*\Enabled).Value)
# check if the session configuration is enabled
$SessionConfig = Get-PSSessionConfiguration -Name "PowerShell.$($PSVersionTable.PSVersion)"
$IsSessionEnabled = [bool]::Parse($SessionConfig.Enabled)
# Alt: (Get-Item "WSMan:\localhost\Plugin\PowerShell.$($PSVersionTable.PSVersion)\Enabled").Value
# check if the session config allows remote access to local admins
$Path = "WSMan:\localhost\Plugin\PowerShell.$($PSVersionTable.PSVersion)\Resources\*\Security\*\Sddl"
$Acls = (ConvertFrom-SddlString (Get-Item $Path).Value).DiscretionaryAcl
$IsSessionAllowed = $Acls -contains 'BUILTIN\Administrators: AccessAllowed'
# output
$Result = $IsServiceRunning -and $IsListenerEnabled -and $IsSessionEnabled -and $IsSessionAllowed
if ($ExitIfFalse -and -not $Result) {
Write-Verbose 'PS Remoting is not enabled' -Verbose
exit
}
Write-Output $Result
}
function Get-GhaStep {
<#
.SYNOPSIS
Get the Steps of the currently running Job, of the GitHub Actions Workflow
#>
$Path = Split-Path (Get-Service actions.runner.*).BinaryPathName.Replace('"',$null)
$File = Get-ChildItem $Path\..\_diag\Worker_* | Sort-Object LastWriteTime -Descending | select -First 1
$text = Get-Content -Path $File.FullName
$total = ($text | Select-String 'Total job steps: (\d+)').Matches.Groups[1].Value -as [int]
$steps = $text | Select-String "Processing step: DisplayName='(.+)'" | foreach {$_.Matches.Groups[1].Value}
$i=0 ; $steps | foreach {
if ($env:GITHUB_WORKFLOW_REF) {$yaml = Split-Path $env:GITHUB_WORKFLOW_REF.Split('@')[0] -Leaf}
else {$yaml = $null}
[pscustomobject] @{
PSTypeName = 'GithubActions.Step'
File = $yaml
Workflow = $env:GITHUB_WORKFLOW
Job = $env:GITHUB_JOB
Step = $_
Index = ++$i
Count = $total
IsFirst = $i -eq 1
IsLast = $i -eq $total
JobRun = $env:GITHUB_RUN_NUMBER
}
}
}
function Test-IsGhaStep ([switch]$ExitIfNotTrue) {
<#
.SYNOPSIS
Checks if this is running inside a GitHub Actions Workflow Step
#>
$HasGhaEnv = Test-Path Env:\GITHUB_RUN_ID # <-- unique number for each workflow run
if ($ExitIfNotTrue -and -not $HasGhaEnv) {
Write-Verbose 'This is not running inside a Step of a GitHub Actions Workflow' -Verbose
exit # <-- this will exit the parent scope as well, as-in it will exit the current GHA step
}
Write-Output $HasGhaEnv
}
#endregion
# M A I N
# ---------
# check pre-requisites
Test-IsGhaStep -ExitIfNotTrue | Out-Null
Test-IsPSRemotingEnabled -ExitIfFalse | Out-Null
# get the current step
$Step = Get-GhaStep | Select-Object -Last 1
# spin up an external process or connect to an existing one
if ($Step.IsFirst) {$Session = New-PSSession -ComputerName . -Name GhaSteps -EA Stop}
else {$Session = Connect-PSSession -ComputerName . -Name GhaSteps -EA Stop}
# run user's code on the external process
Invoke-Command -Session $Session -FilePath $File
# stop the external process or disconnect from it
if ($Step.IsLast) {Remove-PSSession -Session $Session}
else {Disconnect-PSSession -Session $Session | Out-Null}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment