Intune’s Win32 app channel is the right place to deploy anything that is not a Store app or a trivially simple line of business MSI, but the day to day workflow is painful. You drop an installer into a folder, run IntuneWinAppUtil.exe with interactive prompts, upload the resulting .intunewin through the admin portal, and then re-enter detection rules, requirements, and command lines by hand. Every monthly update repeats the whole sequence, and the metadata drifts between your dev and prod tenants until nobody trusts what is deployed where.

This post walks through a small, opinionated GitHub Actions pipeline that turns Win32 packaging into a Git backed, push to deploy workflow. Each application lives in a folder of one repository: an installer, a YAML manifest with the metadata, and an optional install script. On push, the pipeline builds the .intunewin, uploads it to Intune through Microsoft Graph using OIDC federation (no stored secrets), and patches the metadata so the Intune app definition always matches what is in Git.

Prerequisites

  • An Entra ID tenant with Intune licensing and at least one Windows pilot group.
  • An Entra app registration with the DeviceManagementApps.ReadWrite.All application permission (admin consented).
  • A federated credential on that app registration pointing at your GitHub repository and branch.
  • The latest IntuneWinAppUtil.exe from the official Microsoft IntuneWin32App release page, committed to tools/ in the repo.
  • Comfort with PowerShell 7 and a basic GitHub Actions workflow.

Repository layout

One folder per app, one manifest per app. The pipeline only rebuilds folders touched by the commit, so monthly updates are cheap.

apps/
  7zip/
    7z2407-x64.msi
    manifest.yml
  notepadplusplus/
    npp.8.6.4.installer.x64.exe
    manifest.yml
    install.ps1
.github/
  workflows/
    package-and-deploy.yml
scripts/
  Publish-IntuneApp.ps1
tools/
  IntuneWinAppUtil.exe

The manifest is the contract between Git and Intune. Anything you would normally click into the portal lives here.

displayName: 7-Zip 24.07 (x64)
publisher: Igor Pavlov
description: Open source file archiver
installer: 7z2407-x64.msi
installCommand: msiexec /i 7z2407-x64.msi /qn /norestart
uninstallCommand: msiexec /x {23170F69-40C1-2702-2407-000001000000} /qn /norestart
detection:
  type: msi
  productCode: '{23170F69-40C1-2702-2407-000001000000}'
requirement:
  architectures: [x64]
  minWindowsRelease: '1809'
returnCodes:
  - { code: 0, type: success }
  - { code: 3010, type: softReboot }
assignments:
  required: [Pilot-Workstations]

Building the .intunewin

This part is the easy part. IntuneWinAppUtil.exe is a Windows only console binary, so the runner has to be windows-latest. The -q flag suppresses the interactive prompts.

$source    = "apps/7zip"
$installer = "7z2407-x64.msi"
$output    = "build"
New-Item -ItemType Directory -Path $output -Force | Out-Null
& "tools/IntuneWinAppUtil.exe" -c $source -s $installer -o $output -q

The result is build/7z2407-x64.intunewin. That file is actually a ZIP containing the encrypted payload at IntuneWinPackage/Contents/IntunePackage.intunewin and a Detection.xml with the AES CBC key, IV, MAC, and a SHA-256 of the encrypted blob. You need that XML in two minutes when you call Graph, so do not skip parsing it.

Uploading through Microsoft Graph

This is where most people wave their hands. The real upload flow has six steps:

  1. POST /deviceAppManagement/mobileApps to create a win32LobApp shell with metadata.
  2. POST /microsoft.graph.win32LobApp/contentVersions on that app to get a content version ID.
  3. POST /contentVersions/{id}/files with the file size. Graph hands back an Azure Storage SAS URI.
  4. PUT the encrypted payload to Blob Storage in 6 MB chunks using the Block Blob API, then commit the block list.
  5. POST /contentVersions/{id}/files/{id}/commit with fileEncryptionInfo from Detection.xml.
  6. PATCH the app, setting committedContentVersion to the new version.

The chunked upload is the part that breaks for most people. Here is a working helper that has survived files larger than 1 GB:

function Invoke-IntuneChunkedUpload {
  param(
    [string]$SasUri,
    [string]$EncryptedFilePath
  )

  $chunkSize = 6MB
  $blockIds  = New-Object System.Collections.Generic.List[string]
  $stream    = [IO.File]::OpenRead($EncryptedFilePath)
  $buffer    = New-Object byte[] $chunkSize
  $index     = 0

  try {
    while (($read = $stream.Read($buffer, 0, $chunkSize)) -gt 0) {
      $blockId = [Convert]::ToBase64String(
        [Text.Encoding]::ASCII.GetBytes(("block-{0:D6}" -f $index)))
      $blockIds.Add($blockId)
      $body = if ($read -eq $chunkSize) { $buffer } else { $buffer[0..($read - 1)] }
      Invoke-RestMethod -Method Put -Uri "$SasUri&comp=block&blockid=$blockId" `
        -Body $body -Headers @{ "x-ms-blob-type" = "BlockBlob" }
      $index++
    }
  } finally { $stream.Dispose() }

  $xml = '<?xml version="1.0"?><BlockList>'
  foreach ($id in $blockIds) { $xml += "<Latest>$id</Latest>" }
  $xml += '</BlockList>'

  Invoke-RestMethod -Method Put -Uri "$SasUri&comp=blocklist" `
    -Body $xml -ContentType 'application/xml'
}

Two gotchas worth a callout. First, the SAS URI Graph returns is short lived, often around an hour. If a chunk upload retries past that window, ask Graph to renew it with a POST to /files/{id}/renewUpload. Second, the fileEncryptionInfo block is mandatory on commit. Skip it and the app will appear in Intune as successfully uploaded, then fail at install time on every device with no useful error in the portal.

function Get-IntuneFileEncryptionInfo {
  param([string]$IntunewinPath)
  $tmp = New-Item -ItemType Directory -Path "$env:TEMP/$(New-Guid)"
  Expand-Archive -Path $IntunewinPath -DestinationPath $tmp -Force
  [xml]$d = Get-Content "$tmp/IntuneWinPackage/Metadata/Detection.xml"
  @{
    encryptionKey        = $d.ApplicationInfo.EncryptionInfo.EncryptionKey
    macKey               = $d.ApplicationInfo.EncryptionInfo.macKey
    initializationVector = $d.ApplicationInfo.EncryptionInfo.InitializationVector
    mac                  = $d.ApplicationInfo.EncryptionInfo.Mac
    profileIdentifier    = 'ProfileVersion1'
    fileDigest           = $d.ApplicationInfo.EncryptionInfo.FileDigest
    fileDigestAlgorithm  = 'SHA256'
  }
}

Putting it all together

The workflow itself is short, because all of the heavy lifting is in Publish-IntuneApp.ps1. Keeping the logic in a script rather than inline YAML lets you cover it with Pester tests on your laptop.

name: package-and-deploy

on:
  push:
    branches: [main]
    paths: ['apps/**']

permissions:
  id-token: write
  contents: read

jobs:
  package:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4

      - name: Federated login to Azure
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          allow-no-subscriptions: true

      - name: Acquire Graph token
        id: token
        shell: pwsh
        run: |
          $t = az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv
          "::add-mask::$t" | Out-Host
          "TOKEN=$t" | Out-File $env:GITHUB_OUTPUT -Append

      - name: Build and publish changed apps
        shell: pwsh
        run: ./scripts/Publish-IntuneApp.ps1 -Token '${{ steps.token.outputs.TOKEN }}'

The script walks every apps/* folder that changed in the commit, runs IntuneWinAppUtil.exe, reads manifest.yml, looks up the app in Graph by display name (creates it if missing), uploads the new content version, commits with the encryption info, and PATCHes committedContentVersion together with any metadata that drifted from the manifest. Group assignments are reconciled the same way: read the desired state from YAML, diff against Graph, and add or remove assignments to match.

Closing

Once this is wired up, a monthly app refresh becomes a one line change: bump the installer file name in the manifest, commit, push. The pipeline does the rest, your dev and prod tenants stay in sync because they read the same Git, and you get a full audit trail of every change to every detection rule and command line. The most common failure mode is forgetting fileEncryptionInfo on commit, so log loudly if it is missing and fail the job. If you outgrow a single repo, the natural next step is splitting per app into a reusable workflow template and publishing the .intunewin checksums to a signed release tag, but that is a topic for another post.