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.
- Tool: kkamegawa/Relaypublisher
- Skill for writing manifests: tfsugjp/skills - relaypublisher-manifest
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
- Register an application in Entra ID
- Write the manifest (YAML). The skill makes this easy.
- Put the YAML files in a private repository (a public one also works, but…). A winget-like folder structure is a good choice.
- Create the CI (Azure Pipelines)
- Get relaypublisher inside the CI
- 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.
- In the Entra admin center, create a new App registration.
- 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.)
- 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.
- 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 setSha256, 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 thepr:trigger in YAML. To run the pipeline on PRs, add it under Branch policies → Build validation for the main branch. For PR builds,Build.ReasonisPullRequest, 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)
BUILD_SOURCEVERSION: $(Build.SourceVersion)
EXPECTED_TENANT_ID: $(EXPECTED_TENANT_ID)
- publish: $(Build.ArtifactStagingDirectory)/publish-result.json
displayName: Publish result artifact
artifact: publish-result
condition: always()
💡 Tip
- Publish only the changes:
plancompares with the base ref and picks only the changed manifests. So even if I change several packages in separate commits, they are published together in one run.- Dry-run by default: The pipeline publishes for real only on "automatic CI on main" or "a manual run with
allowManualPublish=true."- Re-run only Publish: If you put the ID of an earlier run in
sourceBuildId, you can publish again without rebuilding the packages.- Approval gate: If you add an approval check to
environment: intuneapps-production, someone can review before publishing.lockBehavior: sequential: This stops two publish runs from running at the same time.
Get relaypublisher inside the CI
You can get relaypublisher in any of these ways. Pick the one that is easiest for you to run.
- GitHub artifacts
- NuGet (mirroring it to an Azure Artifacts feed is handy)
- Homebrew
In the pipeline above, install-relaypublisher.ps1 installs the latest version from Azure Artifacts. Write this script to match the way you get the tool. (Please check the README for the package name and feed name.) I have also submitted it to winget, but it hasn't been accepted yet…
⚠️ Caution
Creating intunewin files (package) only works on Windows. That's why only the Package stage runs onwindows-latest. plan / validate / publish also work on Linux, so I use ubuntu for them because it's faster and cheaper.
Merge a PR and test it
- Create a branch and add your manifest and scripts.
- Open a PR, and build validation runs. Publish runs in dry-run mode, so nothing is registered in Intune. Check the logs to see which app will be assigned to which group.
- After the review, merge the PR.
- CI runs on main, and after approval, the app is published to Intune.
- In the Intune admin center, check the app and its assignments. Then make sure it installs on a test device.
- The publish result is saved in the
publish-resultartifact, which is useful for tracking later.
Summary
The setup takes some time, but once it's done, registering apps in Intune becomes much easier. At home, I don't even want to manage my devices without it anymore. After the first setup, you can add apps just like you do with winget-pkgs. Please give it a try. I'm happy to get feedback on GitHub Issues.
Top comments (0)