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 Microsoft.Graph (all as current user to avoid any pesky admin rights restrictions).

#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] {
        $_.message 
        exit
    }
}

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] {
        $_.message 
        exit
    }
}


#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] {
        $_.message 
        exit
    }
}



#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 = "https://graph.microsoft.com/beta/organization"
$tenantdetails = (Invoke-MgGraphRequest -Uri $uri -Method Get -OutputType PSObject).value
$tenantid = $tenantdetails.id
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
PackageVersion: 11.0.0.49893
License: 2020 © Foxit Software Incorporated. All rights reserved.
InstallerType: exe
Tags:
- PDF
- Foxit
- ICON=https://cdn.imgbin.com/1/24/20/imgbin-foxit-reader-pdf-foxit-software-computer-icons-adobe-acrobat-top-view-m5LPTpiUBTVMVjaHzkfHBRtDb.jpg
- 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.
PackageUrl: https://www.foxitsoftware.com/pdf-editor/
Installers:
- Architecture: x86
  InstallerUrl: https://cdn01.foxitsoftware.com/product/reader/desktop/win/11.0.0/FoxitReader110_L10N_Setup_Prom.exe
  InstallerSha256: 6D33CA56B5C6E7F412FF7E7AF6A78036DF4E7AFFD1754975CA9A0A89DF9D570C
  InstallerSwitches:
    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 Entra ID 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 @"
"$filename"
"@
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"
    $IntuneWinFile.Name

    # 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

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

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


#Uninstall
$uninstallid = $uninstallgroup.Id
Add-IntuneWin32AppAssignmentGroup -Include -ID $Win32App.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!

2 thoughts on “Automating App deployment with Winget and Powershell”

  1. Absolutely LOVE this idea but I cannot get this to work. I’ve ran add-winget-package.ps1 -yamlFile https://github.com/andrew-s-taylor/winget/blob/main/manifests/f/Foxit/FoxitReader/11.0.0.49893/Foxit.FoxitReader.yaml and it seems to package everything but fails when uploading to Intune. I’ve tried my own yaml as well but getting the exact same errors each time, that’s why I tried the existing FoxitReader one in case something in my yaml was wrong.

    Here is what I’m getting, any help is much appreciated and great work on this tool!!

    -Install.intunewin
    New-IntuneWin32AppDetectionRuleFile : Cannot validate argument on parameter ‘Path’. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
    At C:\Program Files\WindowsPowerShell\Scripts\add-winget-package.ps1:244 char:75
    + … New-IntuneWin32AppDetectionRuleFile -Existence -Path “$fpath” -FileOr …
    + ~~~~~~~~
    + CategoryInfo : InvalidData: (:) [New-IntuneWin32AppDetectionRuleFile], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationError,New-IntuneWin32AppDetectionRuleFile

    WARNING: 419-2202284207 contains unsupported file extension. Supported extensions are ‘.png’, ‘.jpg’ and ‘.jpeg’
    New-IntuneWin32AppIcon : Cannot validate argument on parameter ‘FilePath’. System error.
    At C:\Program Files\WindowsPowerShell\Scripts\add-winget-package.ps1:251 char:46
    + $Icon = New-IntuneWin32AppIcon -FilePath $ImageFile
    + ~~~~~~~~~~
    + CategoryInfo : InvalidData: (:) [New-IntuneWin32AppIcon], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationError,New-IntuneWin32AppIcon

    Add-IntuneWin32App : Cannot validate argument on parameter ‘DisplayName’. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
    At C:\Program Files\WindowsPowerShell\Scripts\add-winget-package.ps1:252 char:71
    + … p -FilePath $IntuneWinFile.FullName -DisplayName $DisplayName -Descri …
    + ~~~~~~~~~~~~
    + CategoryInfo : InvalidData: (:) [Add-IntuneWin32App], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationError,Add-IntuneWin32App

    Get-IntuneWin32App : Cannot validate argument on parameter ‘DisplayName’. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
    At C:\Program Files\WindowsPowerShell\Scripts\add-winget-package.ps1:256 char:49
    + $Win32App = Get-IntuneWin32App -DisplayName $DisplayName -Verbose
    + ~~~~~~~~~~~~
    + CategoryInfo : InvalidData: (:) [Get-IntuneWin32App], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationError,Get-IntuneWin32App

    Add-IntuneWin32AppAssignmentGroup : Cannot validate argument on parameter ‘ID’. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
    At C:\Program Files\WindowsPowerShell\Scripts\add-winget-package.ps1:260 char:48
    + Add-IntuneWin32AppAssignmentGroup -Include -ID $Win32App.id -GroupID …
    + ~~~~~~~~~~~~
    + CategoryInfo : InvalidData: (:) [Add-IntuneWin32AppAssignmentGroup], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationError,Add-IntuneWin32AppAssignmentGroup

    Add-IntuneWin32AppAssignmentGroup : Cannot validate argument on parameter ‘ID’. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
    At C:\Program Files\WindowsPowerShell\Scripts\add-winget-package.ps1:265 char:48
    + Add-IntuneWin32AppAssignmentGroup -Include -ID $Win32App.id -GroupID …
    + ~~~~~~~~~~~~
    + CategoryInfo : InvalidData: (:) [Add-IntuneWin32AppAssignmentGroup], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationError,Add-IntuneWin32AppAssignmentGroup

    Reply
    • Hi Jase,

      Can you check in the temp directory, there should be a new directory with a random number and the date the script was run in there.
      Check if the YAML file and icon are in there. It looks like the script isn’t finding any of the YAML data which would imply it hasn’t downloaded properly.

      If it isn’t downloading, you can always hard-code a download directory at the top of the script:
      $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

      Hopefully that fixes it, but please let me know if not

      Reply

Leave a Comment