Automating App deployment with Winget and Powershell

For anyone in an environment with multiple customers, packaging, configuring and deploying the same app multiple times seems such a waste of valuable time, especially when having to do so with every update too!

Some thanks first, this script has come together with some help from others;
First up, Niels Kok for his excellent Packaging Script which gave me inspiration for this one
Also to Nickolaj Andersen and his Intune module which does a lot of the work on this one
And Phil Jorgensen for his wizardry in getting Winget to work in the System context

Now, on to the script. This one takes a custom Winget YAML manifest, extracts the juicy details and creates a Win32 app, assignment groups, detection methods, uploads and assigns. Create the YAML, run the script and deploy, easy as that. Using a custom manifest of course means the same can be deployed to multiple tenants, but with only one manifest to update with new app versions!

As always, the raw script is on Github and an example manifest is available here. You can also download from PSGallery to make it quicker to deploy apps

Install-Script -Name add-winget-package

Usage: add-winget-package -yamlfile http://url-here

First up, we need to install and import 3 modules: Powershell-YAML, IntuneWin32App and AzureADPreview (all as current user to avoid any pesky admin rights restrictions).
I am also unloading any current AzureAD modules to make sure the preview one is used

#Install MS Graph if not available
if (Get-Module -ListAvailable -Name powershell-yaml) {
    Write-Host "PowerShell YAML Already Installed"
else {
    try {
        Install-Module -Name powershell-yaml -Scope CurrentUser -Repository PSGallery -Force 
    catch [Exception] {

Write-Host "Installing Microsoft Graph modules if required (current user scope)"

#Install MS Graph if not available
if (Get-Module -ListAvailable -Name Microsoft.Graph) {
    Write-Host "Microsoft Graph Already Installed"
else {
    try {
        Install-Module -Name Microsoft.Graph -Scope CurrentUser -Repository PSGallery -Force 
    catch [Exception] {

#Install IntuneWin32App  if not available
if (Get-Module -ListAvailable -Name IntuneWin32App ) {
    Write-Host "IntuneWin32App Module Already Installed"
else {
    try {
        Install-Module -Name IntuneWin32App  -Scope CurrentUser -Repository PSGallery -Force -AllowClobber -AcceptLicense
    catch [Exception] {

#Importing Modules
Import-Module powershell-yaml
import-module IntuneWin32App 
Import-Module microsoft.graph

Next we need to connect to MG Graph for the group creation and Intune Graph for everything else. You get 2 login boxes, but the second one should discover the account so won’t need the password.

I’m also grabbing the tenant ID directly from Azure to avoid any further input

Select-MgProfile -Name Beta
Connect-MgGraph -Scopes  	RoleAssignmentSchedule.ReadWrite.Directory, Domain.Read.All, Domain.ReadWrite.All, Directory.Read.All, Policy.ReadWrite.ConditionalAccess, DeviceManagementApps.ReadWrite.All, DeviceManagementConfiguration.ReadWrite.All, DeviceManagementManagedDevices.ReadWrite.All, openid, profile, email, offline_access

#Get Tenant ID
$uri = ""
$tenantdetails = (Invoke-MgGraphRequest -Uri $uri -Method Get -OutputType PSObject).value
$tenantid = $
Connect-MSIntuneGraph -TenantID $tenantId

Now we need a working directory so I’m creating a random named folder within Temp so it’s well hidden

$directory = $env:TEMP
#Create Temp location
$random = Get-Random -Maximum 1000 
$random = $random.ToString()
$date =get-date -format yyMMddmmss
$date = $date.ToString()
$path2 = $random + "-"  + $date
$path = $directory + "\" + $path2 + "\"
new-item -ItemType Directory -Path $path

We need the filename to import the YAML later and get important details

$filename = $yamlFile.Substring($yamlFile.LastIndexOf("/") + 1)

Before downloading the YAML, time to have a look at it

If we look at the Official Manifest Specifications it is pretty strict on the items, but the Tags is basically free text with up to 16 items, so I’m going to use that for some of the extra fields we need, I’m adding an ICON path, Detection file type, Uninstall Command and the Azure AD Group names for Install and Uninstalling.

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

So now we download it to the temp location we made earlier

Invoke-WebRequest `
   -Uri $yamlFile `
   -OutFile $templateFilePath `
   -UseBasicParsing `
   -Headers @{"Cache-Control"="no-cache"}

Now, import the YAML and grab the fields from the array

[string[]]$fileContent = Get-Content $templateFilePath
$content = ''
foreach ($line in $fileContent) { $content = $content + "`n" + $line }
$obj = ConvertFrom-Yaml $content
$tags = $obj.Tags
foreach ($tag in $tags) {
    if ($tag -like '*ICON*') {
        $icon = $tag
    if ($tag -like '*DETECTION*') {
        $detection = $tag
    if ($tag -like 'UNINSTALLCOMMAND*') {
        $uninstall = $tag
    if ($tag -like '*ADGROUPI*') {
        $adgroupi = $tag
    if ($tag -like '*ADGROUPU*') {
        $adgroupu = $tag

For each variable we have created, we need to split on the “=” to grab the value

In the case of the icon, we also need the file name and path to save to

$icon2 = $icon -split '='
$iconpath = $icon2[1]
$iconname = $iconpath.Substring($iconpath.LastIndexOf("/") + 1)
$icondownload = $path + $iconname

##Download Icon
Invoke-WebRequest `
   -Uri $iconpath `
   -OutFile $icondownload `
   -UseBasicParsing `
   -Headers @{"Cache-Control"="no-cache"}

Now to create the other variables

$detection2 = $detection -split '='
$detectionrule = $detection2[1]

$uninstall2 = $uninstall -split '='
$uninstallcommand = $uninstall2[1]

$adgroupi2 = $adgroupi -split '='
$adgroupinstall = $adgroupi2[1]

$adgroupu2 = $adgroupu -split '='
$adgroupuninstall = $adgroupu2[1]

$publisher = $obj.publisher
$name = $obj.packagename
$description = $obj.shortdescription
$appversion = $obj.PackageVersion
$infourl = $obj.PackageUrl

Create the Azure AD groups

$groupname1 = $name + "-INSTALL"
#Create Install Group
$installgroup = New-MgGroup -DisplayName $adgroupinstall -Description "Install group for $name" -SecurityEnabled -MailEnabled:$false -MailNickName "group" 

$groupname2 = $name + "-UNINSTALL"
#Create Uninstall Group
$uninstallgroup = New-MgGroup -DisplayName $adgroupuninstall -Description "Uninstall group for $name" -SecurityEnabled -MailEnabled:$false -MailNickName "group" 

Next we need to create the install.ps1 file which we’ll wrap into the win32 package. We need to pass through the filename for the manifest so it knows what to use and then call winget pointing to the manifest file. We’re dumping this info into the setup file itself. You’ll note that we don’t even have an installer at this point, we’ll let Winget sort that bit so the packages we are creating here are tiny!

$setupfile = "$path$name-Install.ps1"
$setupfilename = "$name-Install.ps1"
##Create Install File
Set-Content $setupfile @'

$filename2 = 
'@ -NoNewline
add-Content $setupfile @"
add-Content $setupfile @'
$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"


Now we hand over to the Intunewin32 app PS module to do the creation and uploading. I’m also using the extra functionality to add an icon as well for a better user experience. I’m also populating many other fields from the data in the manifest (you can see the end result at the bottom)

# Package as .intunewin file
    $SourceFolder = $path
    $OutputFolder = $path
    New-IntuneWin32AppPackage -SourceFolder $SourceFolder -SetupFile $setupfilename -OutputFolder $OutputFolder -Verbose

    $IntuneWinFile = Get-ChildItem -Path  $path | Where-Object Name -Like "*.intunewin"

    # Create custom display name like 'Name' and 'Version'
    $DisplayName = $name

    # Create detection rule
    $DetectionRule = New-IntuneWin32AppDetectionRuleFile -Existence -Path "$fpath" -FileOrFolder $fname -Check32BitOn64System $false -DetectionType "exists"

    # Add new EXE Win32 app
    $InstallationScriptFile = Get-ChildItem -Path $path | Where-Object Name -Like "*-Install.ps1"
    $InstallCommandLine = "powershell.exe -ExecutionPolicy Bypass -File .\$($InstallationScriptFile.Name)"
    $UninstallCommandLine = $uninstallcommand
    $ImageFile = $icondownload
    $Icon = New-IntuneWin32AppIcon -FilePath $ImageFile
    Add-IntuneWin32App -FilePath $IntuneWinFile.FullName -DisplayName $DisplayName -Description $description -Publisher $publisher -AppVersion $appversion -InformationURL $infourl -Icon $Icon -InstallExperience "system" -RestartBehavior "suppress" -DetectionRule $DetectionRule -InstallCommandLine $InstallCommandLine -UninstallCommandLine $UninstallCommandLine -Verbose

Finally, we need to assign it

    $Win32App = Get-IntuneWin32App -DisplayName $DisplayName -Verbose

$installid = $installgroup.Id
Add-IntuneWin32AppAssignmentGroup -Include -ID $ -GroupID $installid -Intent "available" -Notification "showAll" -Verbose

$uninstallid = $uninstallgroup.Id
Add-IntuneWin32AppAssignmentGroup -Include -ID $ -GroupID $uninstallid -Intent "uninstall" -Notification "showAll" -Verbose

Here is the end result:

Using this, we can package any application, even to the point of storing the installers and manifests in an Azure Blob for custom applications.

And even better, when you update an application, you can use the same process to push out using the winget upgrade –manifest command!

Posted in Intune