For this post I built a small repository of Intune scripts the way the workflow template expects them: two remediations, a Win32 detection script, a platform script, and the Pester suite from the module’s template. One of the remediations reports uptime, and its detection has the bug the linting post opened with:

1
2
3
4
5
6
7
8
$os = Get-CimInstance -ClassName Win32_OperatingSystem
$days = ((Get-Date) - $os.LastBootUpTime).Days
if ($days -gt 14) {
    Write-Output "Up $days days"
    return 1
}
Write-Host "Up $days days"
exit 0

return 1 at script scope is exit 0, so a device up for a month reports “Without issues” and the remediation never runs.

The suite ran this detection through the runtime harness, a real Windows PowerShell 5.1 launch the way the agent does it, and the test passed. It was supposed to pass: it checks that the detection runs cleanly and reports “Without issues” or an issue, and “Without issues” is exactly what the bug produces. The static check in the same suite failed it on line 5. Neither half of a CI setup for Intune scripts covers the other, and this post is about running both: the gate script, the workflow template, the SARIF export and the Pester assertions.

While I personally haven’t been on a team that uses GitHub, or even git, for Intune scripts, I added these features for the professionals that do. It’s what I’d build if I could do it the right way, instead of a share drive full of unmanaged scripts with no comments and no version control. No, I’m not bitter, why do you ask?

The gate script

Invoke-IntuneScriptGate.ps1 ships in the module’s Examples folder. It runs Test-IntuneScript over the paths you give it and reports for a build: a GitHub Actions annotation per finding, a Markdown job summary, an optional SARIF log, and an exit code. On the demo repository, with the environment variables GitHub sets, the console ended like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
IntuneScriptLab: 12 finding(s) in 6 script(s): 2 error(s), 2 warning(s), 8 note(s)
::notice file=Intune/Remediations/Clear-WidgetCache/Detect.ps1,line=3,col=5,title=IslOutputIssue::2 Write-Output calls: Intune reports only the last line (and only its last 2,048 characters). Put the summary last
::error file=Intune/Remediations/Report-Uptime/Detect.ps1,line=5,col=5,title=IslExitCodeIssue::return at script scope ends the script with exit 0; any exit 1 after it never runs, so the remediation never triggers. Use exit 1 directly
::warning file=Intune/Remediations/Report-Uptime/Detect.ps1,line=7,col=1,title=IslOutputIssue::Write-Host is the last thing written, so its text is what Intune reports instead of your summary. Write the summary line last, with Write-Output
::error file=Intune/Win32/Widget/Detect.ps1,line=2,col=45,title=IslOutputIssue::Nothing is ever written to stdout: exit 0 alone means "not detected". Write-Output a line before exit 0 on the installed path
::warning file=Intune/Win32/Widget/Detect.ps1,line=1,col=12,title=IslOutputIssue::Get-ItemPropertyValue writes an error record to stderr when its target is missing, which makes the app 'not detected'. Use -ErrorAction SilentlyContinue (or Test-Path first) on the not-installed path
SARIF written to C:\Users\...\isl-post9\intune-script-lab.sarif
::group::Gate
2 finding(s) at Error or above; failing the build
::endgroup::

I’ve cut the annotation list down to five. The other seven are the context note every script gets until its type is declared and a user-context note on the platform script. The full run took 2.2 seconds and exited 1.

Those ::error lines are GitHub’s workflow commands. A runner reads them from plain stdout and turns each one into an annotation on the file and line in the pull request’s diff, so the reviewer sees the finding where the code is. That’s why the gate writes them with Write-Host, and why it suppresses PSScriptAnalyzer’s rule against that at the top of the file. A workflow command takes its message on one line, so the gate percent-encodes the characters that would break it:

1
2
3
4
5
function ConvertTo-AnnotationText {
    # Workflow commands take a percent-encoded message on one line
    param([string]$Text)
    $Text.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A')
}

The file= and title= values get the same encoding, plus the : and , that separate the command’s properties. The demo’s paths are relative and come out unchanged, but a script outside the root keeps its absolute path, and an unencoded C: would end the property list at the drive letter:

1
2
3
4
5
6
function ConvertTo-AnnotationProperty {
    # A property value (file=, title=) also encodes the ':' and ',' that delimit the command, or a
    # path outside -Root, which stays absolute, ends the property list at its drive letter
    param([string]$Text)
    (ConvertTo-AnnotationText -Text $Text).Replace(':', '%3A').Replace(',', '%2C')
}

-FailOn picks the lowest severity that fails the build: Error by default, Warning to be stricter, Information to fail on everything, or None to report without failing. The verdict is the last thing the script decides:

1
2
3
4
5
# Assigned from an if statement, a one-item array comes back unwrapped on Windows PowerShell 5.1
$failing = @()
if ($FailOn -ne 'None') {
    $failing = @($findings | Where-Object { $severityRank[$_.Severity] -ge $severityRank[$FailOn] })
}

The comment explains the shape: on Windows PowerShell 5.1, a value assigned straight from an if statement loses its array wrapper when it holds one item, and the gate runs on both hosts.

Annotations default to on when GITHUB_ACTIONS is set. On any other CI they’re plain lines in the log, and -Annotate:$false turns them off. -PassThru returns the findings for whatever your pipeline does next.

The gate works the same on your own machine. That’s the quickest way to see what a pull request will say before you push it. -Annotate:$false keeps the workflow commands out of your console, and -FailOn Warning shows what a stricter gate would stop:

1
2
3
$gate = Join-Path (Get-Module IntuneScriptLab -ListAvailable | Select-Object -First 1).ModuleBase 'Examples\Invoke-IntuneScriptGate.ps1'
& $gate -Path .\Intune -FailOn Warning -Annotate:$false
$LASTEXITCODE

The gate also imports the module from the folder above itself before trying the installed one. That’s for repositories that check a copy of the module in, which the workflow supports.

A summary for the pull request

When GITHUB_STEP_SUMMARY is set, the gate appends a Markdown table to the job’s summary page. The demo’s started like this:

1
2
3
4
5
6
7
8
## IntuneScriptLab: 12 finding(s) in 6 script(s)

2 error(s), 2 warning(s), 8 note(s); gate: fail on Error.

| Severity | Rule | Script | Line | Message |
|---|---|---|---:|---|
| Error | IslExitCodeIssue | Intune/Remediations/Report-Uptime/Detect.ps1 | 5 | return at script scope ends the script with exit 0; any exit 1 after it never runs, so the remediation never triggers. Use exit 1 directly |
| Warning | IslOutputIssue | Intune/Remediations/Report-Uptime/Detect.ps1 | 7 | Write-Host is the last thing written, so its text is what Intune reports instead of your summary. Write the summary line last, with Write-Output |

The table lists every finding in the order the analysis returned them; I’ve kept two rows. Paths are relative to GITHUB_WORKSPACE, so they read the same on every runner. -SummaryPath writes the same table to any file, so another CI can post it as a pull request comment.

Copying the workflow

Examples/intune-script-gate.yml is the workflow to copy into .github/workflows/. It triggers on pull requests and pushes to main that touch a .ps1 file, and its settings are four environment variables:

1
2
3
4
5
env:
  SCRIPT_PATHS: ./Intune
  FAIL_ON: Error
  ISL_MODULE_PATH: ''
  UPLOAD_SARIF: 'false'

The analyze job runs on windows-latest, installs the module from the PowerShell Gallery unless ISL_MODULE_PATH points at a checked-in copy, finds the gate script next to whichever module it ended up with, and runs it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
- name: Analyze
  shell: pwsh
  run: |
    # The gate script sits in the module's Examples folder and imports the module beside it
    $manifest = if ($env:ISL_MODULE_PATH) { (Resolve-Path $env:ISL_MODULE_PATH).Path }
    else {
        (Get-Module -Name IntuneScriptLab -ListAvailable | Sort-Object Version -Descending |
            Select-Object -First 1).Path
    }
    $gate = Join-Path (Split-Path $manifest -Parent) 'Examples\Invoke-IntuneScriptGate.ps1'
    $paths = $env:SCRIPT_PATHS -split ',' | ForEach-Object { $_.Trim() }
    & $gate -Path $paths -FailOn $env:FAIL_ON -SarifPath "$env:RUNNER_TEMP\intune-script-lab.sarif"

A checked-in copy is the way to pin a version. With ISL_MODULE_PATH set, the job uses that copy as it is and never installs a Gallery version behind it, so a module update can’t change your gate’s verdicts until you update the copy.

Test-IntuneScript itself doesn’t need Windows; I’ve run the rules in a Linux container while writing this series. The template still uses windows-latest for both of its jobs. Both check out with actions/checkout@v7, and the SARIF upload uses github/codeql-action/upload-sarif@v4, since GitHub deprecates CodeQL Action v3 in December 2026.

SARIF for code scanning

With -SarifPath, the gate also calls Export-IntuneFindingSarif, which writes the findings as a SARIF 2.1.0 log. The template’s last step uploads it with github/codeql-action/upload-sarif when UPLOAD_SARIF is 'true', and the findings then appear in the repository’s Security tab and as code scanning alerts on the pull request. Code scanning is free on public repositories, and on a private one it needs GitHub’s paid security license. The upload is off by default.

One result from the demo’s log:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
  "ruleId": "IslExitCodeIssue",
  "ruleIndex": 2,
  "level": "error",
  "message": {
    "text": "return at script scope ends the script with exit 0; any exit 1 after it never runs, so the remediation never triggers. Use exit 1 directly"
  },
  "locations": [
    {
      "physicalLocation": {
        "artifactLocation": {
          "uri": "Intune/Remediations/Report-Uptime/Detect.ps1",
          "uriBaseId": "%SRCROOT%"
        },
        "region": {
          "startLine": 5,
          "startColumn": 5,
          "snippet": {
            "text": "return 1"
          }
        }
      }
    }
  ],
  "properties": {
    "scriptType": "Detection",
    "evidence": "\"return 1; exit 1\" ran with exit code 0 and the remediation was skipped; Microsoft's sample detection scripts use this pattern (REM-RETURN-EXIT)"
  }
}

The path is relative to %SRCROOT%, a base the log defines as the workspace root, so code scanning can map the result back onto the repository. The evidence travels in properties, where the PSScriptAnalyzer wrapper had to fold it into the message. A finding that a directive suppressed is written with an in-source suppression attached.

Each rule gets an entry with its help text, read from the rule’s own function:

1
2
3
4
5
elseif (Get-Command -Name "Find-$name" -ErrorAction SilentlyContinue) {
    $help = Get-Help -Name "Find-$name" -ErrorAction SilentlyContinue
    $summary = "$($help.Synopsis)".Trim()
    $description = (@($help.Description | ForEach-Object { $_.Text }) -join ' ') -replace '\s+', ' '
}

For IslExitCodeIssue that’s a short description of “Flags exit-code mistakes in detection and remediation scripts.” and a full description that walks through the observed behavior, any non-zero exit running the remediation included. A reviewer who opens the alert gets the reasoning without leaving GitHub. The rule’s default level is the most severe one it produced in that log.

Rules with no function to read, the context note and the policy checks from the tenant audit post, carry a fixed description in the export instead. A policy check has no script behind it, so its result goes into the log without a location, and the policy’s kind and name lead the message.

Assertions with a diagnosis

The runtime side lives in Pester. The module exports seven assertions under Should-* names: Should-HaveIntuneStatus, Should-BeIntuneDetected, Should-NotBeIntuneDetected, Should-HaveIntuneRunState, Should-PassIntuneAnalysis, Should-BeIntuneApplicable and Should-NotBeIntuneApplicable. The functions behind them are Assert-*, with approved verbs, and the Should-* names are aliases, so Import-Module doesn’t warn about unapproved verbs.

They’re built on New-ShouldAssertion, which arrived in Pester 6.2. 6.2.0 has been the Gallery’s current version since September 2026, and each assertion checks for it before doing anything:

1
2
3
4
5
6
function Test-IslPesterAssertionSupport {
    # New-ShouldAssertion arrived in Pester 6.2; without it these functions cannot report properly.
    if (-not (Get-Command -Name New-ShouldAssertion -ErrorAction SilentlyContinue)) {
        throw 'The IntuneScriptLab assertions need Pester 6.2 or later loaded (Import-Module Pester)'
    }
}

The point of a custom assertion is the failure message. A plain $result.Detected | Should-BeTrue can only tell you it expected $true and got $false. Run against the demo’s Win32 detection script, Should-BeIntuneDetected failed with the reason the agent would give:

1
Expected the app to be detected, but Intune would report not detected: 'Exit code 1: only exit 0 can mean installed; Nothing on stdout: exit 0 alone is "not detected"; Output on stderr: any error output means "not detected" even with exit 0 and stdout'

Should-HaveIntuneStatus adds the output and error text Intune would show, and Should-HaveIntuneRunState the start of a platform script’s result message.

The suite template

Examples/IntuneScripts.Tests.ps1.template is a suite to copy next to your scripts as IntuneScripts.Tests.ps1. It assumes the same layout as the gate: Remediations\<package>\Detect.ps1 and Remediate.ps1, Win32\<app>\Detect.ps1 with a Package folder, and PlatformScripts\*.ps1. Every block enumerates a folder with -ForEach, so adding a script adds its tests:

1
2
3
4
5
6
It 'has no warnings or errors: <_.Directory.Name>/<_.Name>' -AllowNullOrEmptyForEach -ForEach (
    Get-ChildItem "$PSScriptRoot\Remediations" -Recurse -Filter *.ps1 -ErrorAction SilentlyContinue) {
    # Detection/Remediation is inferred from the file name; use -ScriptType or a directive
    # comment in the script if your names differ (see the README)
    $_ | Should-PassIntuneAnalysis
}

-AllowNullOrEmptyForEach is there because Pester 6 treats an empty -ForEach as a discovery failure. A repository with no Win32 apps yet would fail before running anything; with the switch, an empty folder means nothing to test.

The runtime tests that change the machine, a remediation that fixes something, an app that installs, are skipped unless the session is elevated, since running as SYSTEM goes through a scheduled task that needs it. The runtime post has the mechanics. The detection tests run in the harness’s default context, the current user, which is enough to catch a script that only works in your own console.

On the demo, unelevated, with Pester 6.2.0 and IntuneScriptLab 0.33.0:

1
2
Tests completed in 8.87s
Tests Passed: 9, Failed: 2, Skipped: 6, Inconclusive: 0, NotRun: 0

The two failures are the static checks on the uptime detection and the Win32 detection. The uptime one reads:

1
2
3
[-] has no warnings or errors: Report-Uptime/Detect.ps1 157ms
 Expected no Test-IntuneScript findings at 'Warning' or above, but got 2:
 '  Detect.ps1:5 [Error] IslExitCodeIssue: return at script scope ends the script with exit 0; any exit 1 after it never runs, so the remediation never triggers. Use exit 1 directly␊  Detect.ps1:7 [Warning] IslOutputIssue: Write-Host is the last thing written, so its text is what Intune reports instead of your summary. Write the summary line last, with Write-Output'

The six skips are the SYSTEM and install tests, plus the Win32 requirement test, skipped for any app without a Requirement.ps1.

The workflow template carries a second job for this suite, runtime, switched off with if: false until IntuneScripts.Tests.ps1 exists. It installs Pester alongside the module and runs Invoke-Pester over the same paths with Run.Exit on, so a failing test fails the job. With a checked-in copy of the module, that job puts the copy’s parent folder at the front of the module path, so the suite’s own Import-Module IntuneScriptLab finds it:

1
2
3
4
if ($env:ISL_MODULE_PATH) {
    $root = Split-Path (Split-Path (Resolve-Path $env:ISL_MODULE_PATH).Path -Parent) -Parent
    $env:PSModulePath = "$root$([IO.Path]::PathSeparator)$env:PSModulePath"
}

Should-PassIntuneAnalysis takes a file, a folder or a pipeline of either, and -MinimumSeverity (Warning by default) and -ScriptType work the way they do on Test-IntuneScript. It leaves out the IslAssumedContext note, which is Information anyway.

What each half caught

On the uptime detection, the runtime test passed and the static test failed, for the reason in the opening: the bug’s symptom is a valid Intune state. The harness reproduced what the device would do, and the device’s behavior was wrong in a way only the code shows.

It works the other way too. No static rule can know that $env:ProgramData\Widget\cache exists on your test machine, that a module the script imports is missing from the image, or that a command behaves differently in the 32-bit host. The harness runs the script and finds out. The suite template’s second remediation test runs each detection in the 32-bit host and in the device’s own 64-bit host, and compares the exit codes. A WOW64 dependence like that is invisible to a static rule. Windows on ARM refuses an explicit x64, so the template picks the host once in its BeforeAll:

1
2
3
4
5
# The device's 64-bit host, for the WOW64 comparison: x64 on x64, arm64 on Windows on ARM
$script:Host64 = if ("$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)" -eq 'Arm64') {
    'arm64'
}
else { 'x64' }

So the gate goes on every pull request, because it’s fast and needs nothing but the scripts. The runtime job goes where you have a Windows runner and scripts that are safe to run on it.

What the gate leaves out

The suite template puts two kinds of file under the script folders that aren’t Intune scripts. IntuneScripts.Tests.ps1 sits next to the scripts, and a Win32 app’s Package folder holds what becomes the .intunewin, such as an install.ps1 the agent runs through the install command as SYSTEM. Analyzed as scripts, the suite’s Import-Module IntuneScriptLab is an IslModuleDependency Warning, and the installer reads as a user-context platform script with a warning for each machine-wide write. At FAIL_ON: Warning, either one would fail the build.

So the gate searches folders itself and drops both before it calls Test-IntuneScript:

1
2
3
4
5
Get-ChildItem -LiteralPath $resolved.ProviderPath -Recurse -Filter *.ps1 -File |
    Where-Object {
        $_.Name -notlike '*.Tests.ps1' -and
        "$($_.DirectoryName)\" -notmatch '(?i)[\\/]Package[\\/]'
    } | ForEach-Object { $_.FullName }

The demo has both files, and neither appears in the six scripts the gate reported. Only the singular Package segment is matched, so a Packages folder holding Win32 apps keeps its detection scripts in. A file named directly on -Path is still analyzed. Pointed at the suite on its own, the gate reported IntuneScriptLab: 3 finding(s) in 1 script(s): 0 error(s), 1 warning(s), 2 note(s).

The demo’s scripts, run results and SARIF log came from IntuneScriptLab 0.33.0 and Pester 6.2.0 on PowerShell 7.6.6, on my desktop, with the GitHub variables set by hand. The gate script and the templates are in the module’s Examples folder.