Note: I originally wrote this article in Japanese, and I used AI to translate it into English. You can read the original post here: RelaypublisherでIntuneへのアプリ登録を劇的に改善する

Uploading Windows and macOS apps to Intune one by one is painful. At the end of August, I built a tool to fix this and started using it. In my environment, it has made things much easier.

I designed the tool, so of course it fits my own needs best. Still, some parts are a bit hard to understand, so I want to explain them in more detail. In this article, I use PowerShell 7 (the MSIX version) as an example. I walk through every step, from writing the manifest to automatic publishing to Intune with Azure Pipelines.

If you have written a winget manifest before, the syntax will feel familiar. I made it close to winget on purpose. Even so, you will probably fail a few times at first, so the skill above helps a lot. I run everything on Azure DevOps, so this article uses Azure Pipelines. If you use GitHub Actions, AI can convert the pipeline for you quite easily.

Steps

  1. Register an application in Entra ID
  2. Write the manifest (YAML). The skill makes this easy.
  3. Put the YAML files in a private repository (a public one also works, but…). A winget-like folder structure is a good choice.
  4. Create the CI (Azure Pipelines)
  5. Get relaypublisher inside the CI
  6. Merge a PR and test it

Register an application in Entra ID

relaypublisher registers apps in Intune through Microsoft Graph. So first, I register an application in Entra ID and make it usable from Azure DevOps or GitHub.

  1. In the Entra admin center, create a new App registration.
  2. Under API permissions, add the Microsoft Graph application permissions needed for Intune app management, and grant admin consent. (Please check the README for the exact permissions.)
  3. In Azure DevOps, create an Azure Resource Manager connection under Service connections. I recommend workload identity federation, because you don't need to manage secrets.
  4. Write down the connection name, because the pipeline uses it. (In this article, it is Microsoft Azure.)

Save the tenant ID as a protected (secret) pipeline variable named EXPECTED_TENANT_ID.

Tips for writing the YAML

You can write a manifest by hand, but the relaypublisher-manifest skill in tfsugjp/skills makes it much easier. Load the skill into an agent such as GitHub Copilot or Claude Code, and ask something like "Create a manifest to deploy the PowerShell 7.6.8 MSIX to Intune." It will create a template for you. I ask Copilot to do this every time.

Some packages, like MSIX, can't become a Win32 app as they are. For these, you will probably need install, uninstall, and detection scripts. On Windows, you create an .intunewin file with IntuneWinAppUtil.exe. This tool is not open source, so relaypublisher downloads the latest version each time. It only runs on Windows, so if you build Windows packages, use a CI that has Windows runners.

Detection script (detect-x64.ps1)

$version = [version]'7.6.8.0' $architecture = 'X64' $installed = Get-AppxPackage -AllUsers -Name Microsoft.PowerShell -ErrorAction Stop | Where-Object { $_.Version -eq $version -and $_.Architecture.ToString() -eq $architecture } $provisioned = Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object { $_.DisplayName -eq 'Microsoft.PowerShell' -and $_.Version -eq $version -and $_.PackageName -match '_x64_' } if ($installed -or $provisioned) { Write-Output 'PowerShell 7.6.8 x64 is installed.' exit 0 } exit 1 

Install script (install-msix.ps1)

$ErrorActionPreference = 'Stop' $bundlePath = Join-Path $PSScriptRoot 'PowerShell-7.6.8.msixbundle' if (-not (Test-Path -LiteralPath $bundlePath -PathType Leaf)) { Write-Error "PowerShell MSIX bundle not found: $bundlePath" exit 1 } try { Add-AppxProvisionedPackage -Online -PackagePath $bundlePath -SkipLicense -ErrorAction Stop | Out-Null } catch { Write-Error "Failed to provision the PowerShell MSIX bundle: $_" exit 1 } exit 0 

Uninstall script (uninstall-msix.ps1)

$ErrorActionPreference = 'Stop' $version = [version]'7.6.8.0' try { Get-AppxPackage -AllUsers -Name Microsoft.PowerShell -ErrorAction Stop | Where-Object { $_.Version -eq $version } | ForEach-Object { Remove-AppxPackage -Package $_.PackageFullName -AllUsers -ErrorAction Stop } Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object { $_.DisplayName -eq 'Microsoft.PowerShell' -and $_.Version -eq $version } | ForEach-Object { Remove-AppxProvisionedPackage -Online -PackageName $_.PackageName -AllUsers -ErrorAction Stop | Out-Null } } catch { Write-Error "Failed to remove the PowerShell 7.6.8 MSIX package: $_" exit 1 } exit 0 

Manifest

SchemaVersion: "1.0" PackageIdentifier: Microsoft.PowerShell PackageName: PowerShell Publisher: Microsoft Corporation Description: PowerShell 7 cross-platform automation and configuration tool. PackageVersion: 7.6.8 AssignmentSync: merge Apps: - Platform: windows Architecture: x64 Categories: - Data management - Computer management - Development & Design - Productivity InstallerType: win32 DisplayName: PowerShell [Windows x64] Package: IntuneWin: SetupFile: PowerShell-7.6.8.msixbundle RepositoryFiles: - Source: scripts/windows/powershell/7.6.8/install-msix.ps1 Destination: install-msix.ps1 - Source: scripts/windows/powershell/7.6.8/uninstall-msix.ps1 Destination: uninstall-msix.ps1 ExternalFiles: - Type: githubRelease Owner: PowerShell Repository: PowerShell Tag: v7.6.8 AssetName: PowerShell-7.6.8.msixbundle Destination: PowerShell-7.6.8.msixbundle Sha256: "A77EF80D069C342739211904B7917769C190DD0C9CCBE492F21D637F318B5E96" Auth: Type: none Install: CommandLine: powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\install-msix.ps1 UninstallCommandLine: powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\uninstall-msix.ps1 InstallExperience: system RestartBehavior: suppress Detection: Type: script ScriptFile: scripts/windows/powershell/7.6.8/detect-x64.ps1 RunAs32Bit: false EnforceSignatureCheck: false Requirements: MinimumOSVersion: 10.0.19045 Architecture: x64 Assignments: - Target: group GroupId: "<object ID of the target group>" Intent: required 

Here are the key points.

💡 Tip

  • ExternalFiles: Don't put the installer itself in the repository. Get it from a GitHub Release instead. Always set Sha256, so a tampered file will never get in.
  • RepositoryFiles: These scripts from the repository are bundled into the intunewin file.
  • Detection: The detection script is not included in the package. It is registered as an Intune detection rule.
  • Assignments: This item is unique to relaypublisher. winget manifests don't have it.

Put the YAML in a private repository

A manifest includes things like the IDs of target groups, so I recommend keeping it in a private repository (Azure Repos, for example).

A folder structure like winget-pkgs makes management easier.

manifests/ Microsoft/ PowerShell/ 7.6.8/ Microsoft.PowerShell.yaml scripts/ windows/ powershell/ 7.6.8/ install-msix.ps1 uninstall-msix.ps1 detect-x64.ps1 .azurepipelines/ intuneapps.yml install-relaypublisher.ps1 resolve-manifest-base.ps1 

The big difference from winget is that you can choose which groups get the app. So if you want to deploy the same app to different groups, split the folders and create a separate manifest for each.

manifests/Microsoft/PowerShell/7.6.8/dev/Microsoft.PowerShell.yaml # For the dev team (required) manifests/Microsoft/PowerShell/7.6.8/general/Microsoft.PowerShell.yaml # For the whole company (available) 

This way, you can see "which app goes to which group" just by looking at the PR diff.

Create the CI (Azure Pipelines)

The pipeline has three stages.

Stage Agent What it does
Validate ubuntu-latest Finds changed manifests with plan and checks them with validate
Package windows-latest Creates intunewin files with package
Publish ubuntu-latest Registers apps in Intune with publish

For PRs, the pipeline runs in dry-run mode and only checks things. It publishes for real only when changes are merged into main.

💡 Tip: Azure Repos users
Azure Repos ignores the pr: trigger in YAML. To run the pipeline on PRs, add it under Branch policies → Build validation for the main branch. For PR builds, Build.Reason is PullRequest, so the Publish stage automatically runs in dry-run mode.

# Pipeline that publishes manifest changes on main to Intune name: intuneapps-$(Date:yyyyMMdd).$(Rev:r) trigger: batch: false branches: include: - main paths: include: - manifests pr: none parameters: # Manual runs are dry-run by default. Set this to true only when you want to really publish. - name: allowManualPublish displayName: Publish for real on manual runs (disable dry-run) type: boolean default: false # If set, Validate / Package are skipped, and only the Publish stage runs again # using the manifest-list / intune-packages artifacts from the given run. - name: sourceBuildId displayName: Build ID of the run to re-publish from (leave empty for a normal run) type: string default: '' variables: # Allow the service connection with this name for this pipeline in Azure DevOps. - name: azureServiceConnection value: Microsoft Azure stages: - ${{ if eq(parameters.sourceBuildId, '') }}: - stage: Validate displayName: Validate manifests jobs: - job: Validate displayName: Plan and validate pool: vmImage: ubuntu-latest steps: - checkout: self fetchDepth: 0 - task: UseDotNet@2 displayName: Install .NET 10 SDK inputs: packageType: sdk version: 10.0.x - task: NuGetAuthenticate@1 displayName: Authenticate Azure Artifacts - pwsh: | & "$(Build.SourcesDirectory)/.azurepipelines/install-relaypublisher.ps1" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } displayName: Install latest relaypublisher from Azure Artifacts env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) SYSTEM_COLLECTIONURI: $(System.CollectionUri) - pwsh: | & "$(Build.SourcesDirectory)/.azurepipelines/resolve-manifest-base.ps1" displayName: Resolve safe manifest base env: BUILD_REASON: $(Build.Reason) BUILD_REPOSITORY_ID: $(Build.Repository.ID) BUILD_SOURCEBRANCH: $(Build.SourceBranch) BUILD_SOURCEVERSION: $(Build.SourceVersion) BUILD_SOURCESDIRECTORY: $(Build.SourcesDirectory) MANIFEST_OVERRIDE: $(MANIFEST_OVERRIDE) SYSTEM_ACCESSTOKEN: $(System.AccessToken) SYSTEM_COLLECTIONURI: $(System.CollectionUri) SYSTEM_PULLREQUEST_TARGETBRANCH: $(System.PullRequest.TargetBranch) SYSTEM_TEAMPROJECT: $(System.TeamProject) - bash: | set -euo pipefail relaypublisher plan \ --repo-root "$(Build.SourcesDirectory)" \ --manifest-root manifests \ --base-ref "$(ManifestBaseRef)" \ --output "$(Build.ArtifactStagingDirectory)/manifest-list.json" displayName: Plan changed manifests condition: eq(variables['MANIFEST_OVERRIDE'], '') - pwsh: | $ErrorActionPreference = 'Stop' $planArguments = @( 'plan', '--repo-root', '$(Build.SourcesDirectory)', '--manifest-root', 'manifests', '--output', '$(Build.ArtifactStagingDirectory)/manifest-list.json' ) foreach ($path in ($env:MANIFEST_OVERRIDE_PATHS -split ';')) { $planArguments += @('--manifest', $path) } & relaypublisher @planArguments if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } displayName: Plan manually selected manifests condition: ne(variables['MANIFEST_OVERRIDE'], '') env: MANIFEST_OVERRIDE_PATHS: $(ManifestOverridePaths) - bash: | set -euo pipefail relaypublisher validate \ --repo-root "$(Build.SourcesDirectory)" \ --manifest-list "$(Build.ArtifactStagingDirectory)/manifest-list.json" displayName: Validate manifest list - publish: $(Build.ArtifactStagingDirectory)/manifest-list.json displayName: Publish manifest list artifact artifact: manifest-list - stage: Package displayName: Package installers dependsOn: Validate condition: succeeded() jobs: - job: PackageWindows displayName: Package Windows and macOS installers pool: vmImage: windows-latest steps: - checkout: self - task: UseDotNet@2 displayName: Install .NET 10 SDK inputs: packageType: sdk version: 10.0.x - task: NuGetAuthenticate@1 displayName: Authenticate Azure Artifacts - pwsh: | & "$(Build.SourcesDirectory)/.azurepipelines/install-relaypublisher.ps1" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } displayName: Install latest relaypublisher from Azure Artifacts env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) SYSTEM_COLLECTIONURI: $(System.CollectionUri) - download: current artifact: manifest-list - pwsh: | $ErrorActionPreference = 'Stop' $manifestList = '$(Pipeline.Workspace)/manifest-list/manifest-list.json' $outputDirectory = '$(Build.ArtifactStagingDirectory)/out' New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null $list = Get-Content -LiteralPath $manifestList -Raw | ConvertFrom-Json if (@($list.manifests).Count -eq 0) { Write-Host 'No manifests to package; producing an empty package artifact.' exit 0 } relaypublisher package ` --repo-root '$(Build.SourcesDirectory)' ` --manifest-list $manifestList ` --output $outputDirectory if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } displayName: Package installers - publish: $(Build.ArtifactStagingDirectory)/out displayName: Publish package artifact artifact: intune-packages - stage: Publish displayName: Publish to Intune ${{ if eq(parameters.sourceBuildId, '') }}: dependsOn: Package condition: succeeded() ${{ else }}: dependsOn: [] lockBehavior: sequential jobs: - deployment: PublishToIntune displayName: Publish selected apps environment: intuneapps-production pool: vmImage: ubuntu-latest strategy: runOnce: deploy: steps: - checkout: self - ${{ if eq(parameters.sourceBuildId, '') }}: - download: current artifact: manifest-list - download: current artifact: intune-packages - ${{ else }}: - task: DownloadPipelineArtifact@2 displayName: Download manifest-list from build ${{ parameters.sourceBuildId }} inputs: buildType: specific project: $(System.TeamProject) definition: $(System.DefinitionId) buildVersionToDownload: specific pipelineId: ${{ parameters.sourceBuildId }} artifactName: manifest-list targetPath: $(Pipeline.Workspace)/manifest-list - task: DownloadPipelineArtifact@2 displayName: Download intune-packages from build ${{ parameters.sourceBuildId }} inputs: buildType: specific project: $(System.TeamProject) definition: $(System.DefinitionId) buildVersionToDownload: specific pipelineId: ${{ parameters.sourceBuildId }} artifactName: intune-packages targetPath: $(Pipeline.Workspace)/intune-packages - task: UseDotNet@2 displayName: Install .NET 10 SDK inputs: packageType: sdk version: 10.0.x - task: NuGetAuthenticate@1 displayName: Authenticate Azure Artifacts - pwsh: | & "$(Build.SourcesDirectory)/.azurepipelines/install-relaypublisher.ps1" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } displayName: Install latest relaypublisher from Azure Artifacts env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) SYSTEM_COLLECTIONURI: $(System.CollectionUri) - task: AzureCLI@2 displayName: Publish to Intune inputs: azureSubscription: $(azureServiceConnection) scriptType: bash scriptLocation: inlineScript inlineScript: | set -euo pipefail manifest_list="$(Pipeline.Workspace)/manifest-list/manifest-list.json" package_dir="$(Pipeline.Workspace)/intune-packages" result_file="$(Build.ArtifactStagingDirectory)/publish-result.json" mkdir -p "$(Build.ArtifactStagingDirectory)" printf '[]\n' > "$result_file" manifest_count="$(jq -r '.manifests | length' "$manifest_list")" if [[ "$manifest_count" == '0' ]]; then echo 'No manifests to publish; producing an empty publish-result artifact.' exit 0 fi if [[ -z "${EXPECTED_TENANT_ID:-}" || "$EXPECTED_TENANT_ID" == '$('* ]]; then echo 'EXPECTED_TENANT_ID must be configured as a protected pipeline variable.' >&2 exit 1 fi if [[ "$BUILD_SOURCEBRANCH" == 'refs/heads/main' && "$BUILD_REASON" == 'IndividualCI' ]]; then dry_run=false elif [[ "$BUILD_SOURCEBRANCH" == 'refs/heads/main' && "${ALLOW_MANUAL_PUBLISH:-False}" == 'True' ]]; then dry_run=false echo 'Manual run on main with allowManualPublish=true; publishing for real.' else dry_run=true echo 'This is not an automatic main CI build; running publish in dry-run mode.' fi export AZURE_TOKEN_CREDENTIALS=AzureCliCredential args=(publish --repo-root "$(Build.SourcesDirectory)" --manifest-list "$manifest_list" --package-dir "$package_dir" --expected-tenant "$EXPECTED_TENANT_ID" --source-commit "$BUILD_SOURCEVERSION" --result-file "$result_file") if [[ "$dry_run" == true ]]; then args+=(--dry-run) fi relaypublisher "${args[@]}" env: ALLOW_MANUAL_PUBLISH: ${{ parameters.allowManualPublish }} AZURE_TOKEN_CREDENTIALS: AzureCliCredential BUILD_REASON: $(Build.Reason) BUILD_SOURCEBRANCH: $(Build.SourceBranch) ...
Source:https://dev.to/kkamegawa/make-intune-app-publishing-much-easier-with-relaypublisher-ch4