Packaging and publishing Intune apps using Winget and Azure Devops CI/CD Pipeline – packaging as code


Application packaging can be one of the most time and resource intensive parts of managing a modern desktop estate, especially if you look after multiple customers.

Winget gets us part of the way there (see my previous scripts here and here), but they all use the community repository which could be a security concern for some and also restricts which apps you can use.

After discovering you can use custom manifests to deploy apps (see my post here), I have combined that into a DevOps Pipeline which will automatically create your IntuneWin package, AAD groups and assign it as soon as it detects a new YAML file in the Github Repository.

Obviously you want your repo to be private as well so we’ll be using a token to authenticate.

For those of you with mutliple tenants, fear not, it can be cross-tenant!

Creating Azure App Registration

The first thing we need to do is create an App Registration in Azure AD which can be used to create the apps and groups.

Navigate to Azure AD and click App Registrations, then create a new Application

Give your application a name you will recognise and then select either Single Tenant or Multi-Tenant depending on your requirements, then click Register

Grab the Application (Client ID) from the Overview screen

Now click Authentication

Click Add Platform

You want Mobile or Desktop Application

Tick the 3 boxes and click Configure

Next, select API Permissions and click Add a Permission

Select Microsoft Graph

Select Application Permissions

Add these:

  • DeviceManagementApps.ReadWrite.All
  • DeviceManagementConfiguration.ReadWrite.All
  • DeviceManagementManagedDevices.ReadWrite.All
  • Domain.Read.All
  • Group.ReadWrite.All
  • Organization.Read.All

You’ll see it’s still not finished, you need to click Grant admin consent

Last step here, click Certificates & secrets

Add a new secret

Add a name you’ll remember and set an expiry (I’m using 24 months because I’m lazy)

Copy the Secret Value somewhere safe, it won’t re-display it and if you lose it you’ll need to create a new one

Creating GitHub Repo and Token

Now we need to create a repo we can use to host both the YAML files and the devops pipeline itself.

Create a repository, public will also work, but private probably makes more sense for source code

Now we need to create the token, in GitHub navigate to Settings (in the top right menu) – Developer Settings (at the bottom)

I’m using a Personal Access Token, but the API commands are all compatible with a GitHub App if you would prefer

Generate a new classic token and grab the output, it only needs Repo access

The Script

Before we create a pipeline, we need to add the PowerShell script into the new repo.

The script can be found here

After installing modules and authenticating, the script will first use the GitHub API to find the most recently modified YAML file as this is what will have triggered the pipeline

Then it grabs the app details from the YAML, creates an install script and configures install/uninstall commands and detection using the details we added to the YAML.

Next it will create the intunewin file (it downloaded the utility earlier) and upload it to Intune

It also creates install and uninstall groups and assigns them to the newly created application

Remember these installs all use Winget so the intunewin file does not contain any source code, it is simply telling winget where to grab everything from in the manifest file.

The install script will look like this:

$filename = $filename2.Substring($filename2.LastIndexOf("/") + 1)
   $curDir = Get-Location
   $filebase = Join-Path $curDir $filename
   $Winget = Get-ChildItem -Path (Join-Path -Path (Join-Path -Path $env:ProgramFiles -ChildPath "WindowsApps") -ChildPath "Microsoft.DesktopAppInstaller*_x64*\Winget.exe")
   Start-Process -NoNewWindow -FilePath $winget -ArgumentList "settings --enable LocalManifestFiles"
   Start-Process -NoNewWindow -FilePath $winget -ArgumentList "install --silent  --manifest $filename"

Creating DevOps Pipeline

If you don’t have a DevOps account yet, follow the Microsoft guides here:


Create an Organisation

Once you’re setup, start a new project

Fill in the details

Now click Pipelines

And create a Pipeline

Select GitHub

After authenticating, select the Repo you created earlier

Click Starter Pipeline

In the YML, change the VM image to windows-latest

Click Variables

Add the following:

  • clientid (the app reg ID)
  • clientsecret (the secret value)
  • ownername (the Github account name)
  • reponame (the repo we created earlier)
  • tenantID (the tenant ID we are deploying to)
  • token (your GitHub token)

Add this code to your YML

- task: PowerShell@2
    filePath: '.\add-winget-package-pipeline.ps1'
    arguments: '-reponame $(reponame) -tenant $(tenantID) -clientid $(clientid) -clientsecret $(clientsecret) -ownername $(ownername) -token $(token)'

Click the save button (not save and run, we’re not done yet). Change the filepath if you have renamed the PS file created earlier

Click the 3 dots and select Triggers

We only want this pipeline to trigger if we add or update a YAML file, not if someone updates or something else so override the trigger

Add this to only look at YAML files

That’s the pipeline created and sitting happily waiting for you to add some juicy YAML content


As mentioned earlier, the only things in the repo are manifest files, no source code it present so it’s all lightweight and the manifest is telling Winget what to do.

An example manifest can be found here

PackageIdentifier: Foxit.FoxitReader
Publisher: Foxit
PackageName: FoxitReader
License: 2020 © Foxit Software Incorporated. All rights reserved.
InstallerType: exe
- Foxit
- DETECTION="c:\program files(x86)\Foxit Software\Foxit Reader\FoxitReader.exe"
- UNINSTALLCOMMAND="%ProgramFiles%\Foxit Software\Foxit Reader\unins000.exe" /silent
- ADGROUPI=Intune-App-FoxitReader-Install
- ADGROUPU=Intune-App-FoxitReader-Uninstall 
ShortDescription: Industry's Most Powerful PDF Reader.
- Architecture: x86
  InstallerSha256: 6D33CA56B5C6E7F412FF7E7AF6A78036DF4E7AFFD1754975CA9A0A89DF9D570C
    Silent: /silent /COMPONENTS="pdfviewer,ffSpellCheck,ffse" CPDF_DISABLE="1" /TASKS="startmenufolder"
    SilentWithProgress: /silent /COMPONENTS="pdfviewer,ffSpellCheck,ffse" CPDF_DISABLE="1" /TASKS="startmenufolder"
PackageLocale: en-US
ManifestType: singleton
ManifestVersion: 1.0.0
MinimumOSVersion: 10.0.18362.0

As you can see, I have exteded the Tags field (no limit on this one) to include Detection, Uninstall Commands and the AAD Group Names.

I would also suggest including version numbers in the Package Name if you plan on deploying multiple versions to make things easier (also o the AAD group names)

The InstallerUrl can be absolutely anywhere so you can have your own source files on an Azure Blob or similar if you need to use non-vendor installers.

What else can this do?

Now we have this automated, you can add approvals so the pipeline won’t run until someone approves it.

You’ll also notice the TenantID is a variable and not hard-coded. If you have used a multi-tenant app reg, you can have multiple pipelines within the same DevOps account with different tenants to service multiple environments at the same time.

You could also combine the two and have approvals before it hits each tenant so for example, you deploy to Dev, test and then approve deployment to live, or get client approval to deploy to them.

Hopefully this is of use, DevOps is a powerful tool, especially when combined with PowerShell and Graph to make anything ‘as code’

9 thoughts on “Packaging and publishing Intune apps using Winget and Azure Devops CI/CD Pipeline – packaging as code”

  1. the fail is “$id = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($chunk.ToString(“0000″)))”
    Inside the function –>

    function UploadFileToAzureStorage($sasUri, $filepath, $fileUri) {

    try {

    $chunkSizeInBytes = 1024l * 1024l * $azureStorageUploadChunkSizeInMb

    write-host “Start the timer for SAS URI renewal.”
    $sasRenewalTimer = [System.Diagnostics.Stopwatch]::StartNew()

    write-host “Find the file size and open the file.”
    $fileSize = (Get-Item $filepath).length
    $chunks = [Math]::Ceiling($fileSize / $chunkSizeInBytes)
    $reader = New-Object System.IO.BinaryReader([System.IO.File]::Open($filepath, [System.IO.FileMode]::Open))
    $reader.BaseStream.Seek(0, [System.IO.SeekOrigin]::Begin)

    write-host ” Upload each chunk. Check whether a SAS URI renewal is required after each chunk is uploaded and renew if needed.”
    $ids = @()
    write-host “Antes del bucle”
    for ($chunk = 0; $chunk -lt $chunks; $chunk++) {
    write-host “Dentro del bucle $chunk”
    write-host “antes del system.convert”
    $id = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($chunk.ToString(“0000”)))
    write-host “despues del system.convert”
    $ids += $id

    $start = $chunk * $chunkSizeInBytes
    $length = [Math]::Min($chunkSizeInBytes, $fileSize – $start)
    $bytes = $reader.ReadBytes($length)

    $currentChunk = $chunk + 1

    Write-Progress -Activity “Uploading File to Azure Storage” -status “Uploading chunk $currentChunk of $chunks” `
    -percentComplete ($currentChunk / $chunks * 100)

    UploadAzureStorageChunk $sasUri $id $bytes

    write-host ” Renew the SAS URI if 7 minutes have elapsed since the upload started or was renewed last.”
    if ($currentChunk -lt $chunks -and $sasRenewalTimer.ElapsedMilliseconds -ge 450000) {

    RenewAzureStorageUpload $fileUri



  2. the fail is when i try to upload the file maybe:
    Uploading file to Azure Storage…
    Invoke-UploadWin32Lob : Aborting with exception: Microsoft.PowerShell.Commands.WriteErrorException: PUT https://mmcswdb

  3. Wonderfull article. but i am havig problems with the funtion:
    Invoke-UploadWin32Lob -SourceFile “$intunewincreated” -DisplayName “$DisplayName” -publisher “$publisher” `
    -description “$description” -detectionRules $DetectionRule -returnCodes $ReturnCodes `
    -installCmdLine “$installcommandline” `
    -uninstallCmdLine “$uninstallcommandline”

    I always get this error:
    2022-12-12T12:13:08.7622381Z Invoke-UploadWin32Lob : Aborting with exception: Microsoft.PowerShell.Commands.WriteErrorException: PUT https://mmcswdb
    2022-12-12T12:13:08.7624990Z db1-0410f8dd72ec.intunewin.bin?sv=2016-05-31&sr=b&si=-1695619864&sig=LnT9EVA%2BghrswxRMvuyRB9UKrl2eaeY%2BffYoJdu53%2BM%
    2022-12-12T12:13:08.7626689Z 3D&comp=block&blockid=MDAwMA==
    2022-12-12T12:13:08.7627802Z At C:\Users\useradm\agent\_work\1\s\add-winget-package-pipeline.ps1:2630 char:5
    2022-12-12T12:13:08.7629161Z + Invoke-UploadWin32Lob -SourceFile “$intunewincreated” -DisplayNam …
    2022-12-12T12:13:08.7630040Z + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    2022-12-12T12:13:08.7631002Z + CategoryInfo : NotSpecified: (:) [Write-Error], WriteErrorException
    2022-12-12T12:13:08.7632038Z + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,Invoke-UploadWin32Lob
    2022-12-12T12:13:08.9632543Z ##[error]PowerShell exited with code ‘1’.

  4. Hi Taylor,

    Thank you for the blog and scripts. This is very helpful. Would like to check how would this work with keeping the apps up to date with the proactive remediation tasks? Can we still utilize the same process as in your previous post?

    • Hi,
      Yes, if you are using custom manifests, you just need to amend the remediation script to reference the manifest file when calling the upgrade command (the easiest way is probably to replace the txt with a CSV and then you can grab the app name and manifest in the loop)


Leave a Comment