Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Tests/Parity/assignment-parity.v1.json text eol=lf
88 changes: 88 additions & 0 deletions .github/workflows/mcp-parity.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: MCP parity gate

on:
push:
branches: [main]
paths:
- "Module/**"
- "Tests/Parity/**"
- "Tests/Unit/McpParityFixtures.Tests.ps1"
- ".github/workflows/mcp-parity.yml"
pull_request:
branches: [main]
paths:
- "Module/**"
- "Tests/Parity/**"
- "Tests/Unit/McpParityFixtures.Tests.ps1"
- ".github/workflows/mcp-parity.yml"
workflow_dispatch:

permissions:
contents: read

jobs:
fixture:
name: Verify deterministic PowerShell fixture
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- name: Checkout PowerShell change
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: powershell-reference
persist-credentials: false

- name: Verify the committed PowerShell fixture is deterministic
shell: pwsh
run: |
$generated = Join-Path $env:RUNNER_TEMP 'assignment-parity.v1.json'
./powershell-reference/Tests/Parity/Export-McpParityFixtures.ps1 -OutputPath $generated | Out-Null
$committed = './powershell-reference/Tests/Parity/assignment-parity.v1.json'
$generatedContent = [System.IO.File]::ReadAllText($generated).Replace("`r`n", "`n")
$committedContent = [System.IO.File]::ReadAllText($committed).Replace("`r`n", "`n")
if ($generatedContent -cne $committedContent) {
throw 'PowerShell behavior changed without regenerating the MCP parity fixture.'
}

parity:
name: Verify TypeScript MCP compatibility
needs: fixture
if: ${{ vars.IAC_MCP_REPOSITORY != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- name: Checkout PowerShell change
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: powershell-reference
persist-credentials: false

- name: Checkout MCP implementation
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ${{ vars.IAC_MCP_REPOSITORY }}
path: mcp
token: ${{ secrets.IAC_MCP_REPOSITORY_TOKEN || github.token }}
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
cache-dependency-path: mcp/package-lock.json

- name: Install MCP dependencies
working-directory: mcp
run: npm ci

- name: Test the MCP against this PowerShell change
working-directory: mcp
env:
IAC_POWERSHELL_REPO: ${{ github.workspace }}/powershell-reference
run: |
npm run parity:sync
npm run check
npm run build
10 changes: 9 additions & 1 deletion .github/workflows/pester.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,23 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install Pester 5
- name: Install test dependencies
shell: pwsh
run: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
$existing = Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -ge 5 } | Select-Object -First 1
if (-not $existing) {
Install-Module Pester -MinimumVersion 5.0.0 -Scope CurrentUser -Force -SkipPublisherCheck
}
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) {
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force
}
Import-Module Pester -MinimumVersion 5.0.0
Get-Module Pester | Format-List Name, Version
Get-Module -ListAvailable Microsoft.Graph.Authentication |
Sort-Object Version -Descending |
Select-Object -First 1 Name, Version |
Format-List

- name: Run unit tests
shell: pwsh
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/psscriptanalyzer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
- 'Module/**'
- 'Tests/**'
- 'Register-IntuneAssignmentCheckerApp.ps1'
- 'packaging/**'
- '.PSScriptAnalyzerSettings.psd1'
- '.github/workflows/psscriptanalyzer.yml'
pull_request:
Expand All @@ -15,6 +16,7 @@ on:
- 'Module/**'
- 'Tests/**'
- 'Register-IntuneAssignmentCheckerApp.ps1'
- 'packaging/**'
- '.PSScriptAnalyzerSettings.psd1'
- '.github/workflows/psscriptanalyzer.yml'
workflow_dispatch:
Expand Down Expand Up @@ -49,6 +51,7 @@ jobs:
Get-ChildItem ./Module/IntuneAssignmentChecker -Recurse -File -Include *.ps1, *.psm1
Get-ChildItem ./Tests -Recurse -File -Include *.ps1
Get-Item ./Register-IntuneAssignmentCheckerApp.ps1
Get-ChildItem ./packaging -File -Filter *.ps1
) | Sort-Object FullName
$results = @(
foreach ($file in $files) {
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/publish-module.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ jobs:
Get-ChildItem ./Module/IntuneAssignmentChecker -Recurse -File -Include *.ps1, *.psm1
Get-ChildItem ./Tests -Recurse -File -Include *.ps1
Get-Item ./Register-IntuneAssignmentCheckerApp.ps1
Get-ChildItem ./packaging -File -Filter *.ps1
) | Sort-Object FullName
$results = @(
foreach ($file in $files) {
Expand Down
225 changes: 225 additions & 0 deletions .github/workflows/windows-package.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
name: Windows MSI and WinGet

on:
pull_request:
branches: [main]
paths:
- 'Module/**'
- 'packaging/**'
- '.github/workflows/windows-package.yml'
push:
branches: [main]
paths:
- 'Module/**'
- 'packaging/**'
- '.github/workflows/windows-package.yml'
release:
types: [published]
workflow_dispatch:

permissions:
contents: write
id-token: write
attestations: write

jobs:
build:
name: Build and verify MSI
runs-on: windows-latest
timeout-minutes: 30

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 6.0.2
wix --version

- name: Build MSI
id: package
shell: pwsh
run: |
$manifest = Import-PowerShellDataFile ./Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1
$version = "$($manifest.ModuleVersion)"
if ('${{ github.event_name }}' -eq 'release') {
$tagVersion = '${{ github.event.release.tag_name }}' -replace '^v', ''
if ($tagVersion -cne $version) { throw "Release tag version '$tagVersion' does not match '$version'." }
}
$package = ./packaging/Build-WindowsInstaller.ps1 -Version $version -OutputDirectory ./artifacts
"version=$version" >> $env:GITHUB_OUTPUT
"installer=$($package.Path)" >> $env:GITHUB_OUTPUT
"product_code=$($package.ProductCode)" >> $env:GITHUB_OUTPUT
$package | Format-List

- name: Verify installation and removal
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = '${{ steps.package.outputs.installer }}'
$installLog = Join-Path $env:RUNNER_TEMP 'intune-assignment-checker-install.log'
$removeLog = Join-Path $env:RUNNER_TEMP 'intune-assignment-checker-remove.log'
$verificationScript = Join-Path $env:RUNNER_TEMP 'verify-intune-assignment-checker.ps1'
$commandFolder = Join-Path $env:ProgramFiles 'Intune Assignment Checker\bin'
$normalizedCommandFolder = $commandFolder.TrimEnd([IO.Path]::DirectorySeparatorChar)
$launcherPath = Join-Path $commandFolder 'IntuneAssignmentChecker.cmd'
$bootstrapPath = Join-Path $commandFolder 'Start-IntuneAssignmentChecker.ps1'
$installed = $false
try {
Write-Host "Installing $msi"
$install = Start-Process msiexec.exe -ArgumentList @(
'/i', $msi, '/qn', '/norestart', '/L*V', $installLog
) -Wait -PassThru
$installExitCode = $install.ExitCode
Write-Host "MSI install exit code: $installExitCode"
if ($installExitCode -ne 0) {
Get-Content -LiteralPath $installLog -Tail 200 -ErrorAction SilentlyContinue
throw "MSI installation failed with exit code $installExitCode."
}
$installed = $true

$available = @(Get-Module -ListAvailable IntuneAssignmentChecker |
Where-Object Version -EQ '${{ steps.package.outputs.version }}')
$available | Format-List Name, Version, Path
if ($available.Count -eq 0) { throw 'The installed module was not discoverable through PSModulePath.' }
if (-not (Test-Path -LiteralPath $launcherPath -PathType Leaf)) {
throw "The PowerShell 7 command launcher was not installed at '$launcherPath'."
}
if (-not (Test-Path -LiteralPath $bootstrapPath -PathType Leaf)) {
throw "The deterministic PowerShell launcher was not installed at '$bootstrapPath'."
}
$machinePath = [Environment]::GetEnvironmentVariable('PATH', 'Machine')
$machinePathEntries = @($machinePath -split ';' | ForEach-Object { $_.Trim().TrimEnd([IO.Path]::DirectorySeparatorChar) })
if ($machinePathEntries -notcontains $normalizedCommandFolder) {
throw "The command launcher directory was not added to the machine PATH: '$commandFolder'."
}
$env:IAC_LAUNCHER_PATH = $launcherPath
$launcherOutput = @(& $env:ComSpec /d /s /c 'call "%IAC_LAUNCHER_PATH%" --check' 2>&1)
if ($LASTEXITCODE -ne 0) {
throw "The installed command launcher check failed with exit code ${LASTEXITCODE}: $($launcherOutput -join ' ')"
}
if (($launcherOutput -join [Environment]::NewLine) -notmatch 'is ready in PowerShell 7') {
throw "The installed command launcher did not confirm a PowerShell 7 handoff: $($launcherOutput -join ' ')"
}
$expectedModuleRoot = Join-Path $env:ProgramFiles 'PowerShell\Modules\IntuneAssignmentChecker'
if (($launcherOutput -join [Environment]::NewLine) -notmatch [regex]::Escape($expectedModuleRoot)) {
throw "The command launcher did not report the MSI module source '$expectedModuleRoot': $($launcherOutput -join ' ')"
}

@'
param([Parameter(Mandatory)][string]$Version)
$ErrorActionPreference = 'Stop'
Import-Module IntuneAssignmentChecker -RequiredVersion $Version -Force
$module = Get-Module IntuneAssignmentChecker | Select-Object -First 1
if ($module.Version.ToString() -cne $Version) { throw 'Installed module version mismatch.' }
$expected = @($module.ExportedFunctions.Keys | Where-Object {
$_ -notin @('Get-IntuneAssignmentOperation', 'Invoke-IntuneAssignmentChecker', 'Start-IntuneAssignmentCheckerTui')
})
$verification = & $module {
param([string[]]$Expected)
$parity = Test-IACTuiFeatureParity -CommandName $Expected
$state = New-IACTuiState
$frame = Get-IACTuiFrame -State $state -Width 120 -Height 36
[PSCustomObject]@{
Parity = $parity
Frame = $frame
HitTargetCount = $state.HitTargets.Count
}
} $expected
if (-not $verification.Parity.Complete) {
throw "TUI workflow registry is not in parity with installed exports. Missing: $($verification.Parity.Missing -join ', '); unknown: $($verification.Parity.Unknown -join ', ')."
}
if ($verification.Frame -notmatch 'INTUNE ASSIGNMENT CHECKER' -or $verification.HitTargetCount -lt 12) {
throw 'The installed terminal command center did not render its navigation and mouse targets.'
}
Write-Host "Verified $($verification.Parity.Mapped.Count) installed TUI workflow mappings and $($verification.HitTargetCount) mouse targets."
'@ | Set-Content -LiteralPath $verificationScript -Encoding utf8
& (Join-Path $PSHOME 'pwsh.exe') -NoLogo -NoProfile -File $verificationScript `
-Version '${{ steps.package.outputs.version }}'
$verificationExitCode = $LASTEXITCODE
if ($verificationExitCode -ne 0) {
throw "Installed-module verification failed with exit code $verificationExitCode."
}
}
finally {
Remove-Item Env:IAC_LAUNCHER_PATH -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $verificationScript -Force -ErrorAction SilentlyContinue
if ($installed) {
$remove = Start-Process msiexec.exe -ArgumentList @(
'/x', '${{ steps.package.outputs.product_code }}', '/qn', '/norestart', '/L*V', $removeLog
) -Wait -PassThru
$removeExitCode = $remove.ExitCode
Write-Host "MSI removal exit code: $removeExitCode"
if ($removeExitCode -ne 0) {
Get-Content -LiteralPath $removeLog -Tail 200 -ErrorAction SilentlyContinue
throw "MSI removal failed with exit code $removeExitCode."
}
if (Test-Path -LiteralPath $launcherPath) {
throw "The command launcher remained after MSI removal: '$launcherPath'."
}
$machinePathAfterRemoval = [Environment]::GetEnvironmentVariable('PATH', 'Machine')
$machinePathEntriesAfterRemoval = @($machinePathAfterRemoval -split ';' | ForEach-Object { $_.Trim().TrimEnd([IO.Path]::DirectorySeparatorChar) })
if ($machinePathEntriesAfterRemoval -contains $normalizedCommandFolder) {
throw "The command launcher directory remained in the machine PATH after removal: '$commandFolder'."
}
}
}

- name: Reject executable artifacts
shell: pwsh
run: |
$executables = @(Get-ChildItem -LiteralPath ./artifacts -Recurse -File -Filter *.exe)
if ($executables.Count -gt 0) {
$paths = $executables.FullName -join [Environment]::NewLine
throw "Windows releases must not contain executable artifacts:$([Environment]::NewLine)$paths"
}

- name: Generate WinGet manifests
shell: pwsh
run: |
$version = '${{ steps.package.outputs.version }}'
$url = "https://github.com/${{ github.repository }}/releases/download/v$version/IntuneAssignmentChecker-$version-x64.msi"
./packaging/New-WinGetManifest.ps1 -InstallerPath '${{ steps.package.outputs.installer }}' `
-InstallerUrl $url -Version $version -ProductCode '${{ steps.package.outputs.product_code }}' `
-OutputDirectory ./artifacts/winget

- name: Generate SBOM
uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6
with:
path: ./artifacts/windows-package-staging
format: spdx-json
output-file: ./artifacts/IntuneAssignmentChecker-${{ steps.package.outputs.version }}.spdx.json
upload-artifact: false

- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: intune-assignment-checker-windows-${{ steps.package.outputs.version }}
path: |
artifacts/*.msi
artifacts/*.spdx.json
artifacts/winget/*.yaml
if-no-files-found: error

- name: Attest release installer
if: github.event_name == 'release'
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-path: '${{ steps.package.outputs.installer }}'

- name: Upload release assets
if: github.event_name == 'release'
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$assets = @(
(Get-Item -LiteralPath '${{ steps.package.outputs.installer }}').FullName
(Get-Item -LiteralPath "./artifacts/IntuneAssignmentChecker-${{ steps.package.outputs.version }}.spdx.json").FullName
(Get-ChildItem -LiteralPath ./artifacts/winget -File -Filter *.yaml).FullName
)
if ($assets.Count -lt 5) { throw 'Expected the MSI, SBOM, and three WinGet manifest files.' }
gh release upload '${{ github.event.release.tag_name }}' @assets --clobber
if ($LASTEXITCODE -ne 0) { throw 'GitHub release asset upload failed.' }
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
bin/
obj/
artifacts/
TestResults/
*.user
*.suo
.vs/
13 changes: 13 additions & 0 deletions Module/IntuneAssignmentChecker/Data/GovernanceRules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"schemaVersion": 1,
"rules": [
{ "id": "IAC001", "enabled": true, "severity": "High", "title": "Broad All Users targeting" },
{ "id": "IAC002", "enabled": true, "severity": "High", "title": "Broad All Devices targeting" },
{ "id": "IAC003", "enabled": true, "severity": "Medium", "title": "Required application has no exclusion" },
{ "id": "IAC004", "enabled": true, "severity": "High", "title": "Empty or unresolved target group" },
{ "id": "IAC005", "enabled": true, "severity": "Critical", "title": "Conflicting inclusion and exclusion" },
{ "id": "IAC006", "enabled": true, "severity": "Medium", "title": "Assignment filter result is unknown" },
{ "id": "IAC007", "enabled": true, "severity": "Critical", "title": "Assignment scan coverage is incomplete" },
{ "id": "IAC008", "enabled": true, "severity": "High", "title": "Critical policy or application is unassigned" }
]
}
Loading
Loading