DEV Community

KAMEGAWA Kazushi
KAMEGAWA Kazushi

Posted on Originally published at kkamegawa.hatenablog.jp AI-assisted

Make Intune App Publishing Much Easier with Relaypublisher

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
                    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()
Enter fullscreen mode Exit fullscreen mode

💡 Tip

  • Publish only the changes: plan compares 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 on windows-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

  1. Create a branch and add your manifest and scripts.
  2. 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.
  3. After the review, merge the PR.
  4. CI runs on main, and after approval, the app is published to Intune.
  5. In the Intune admin center, check the app and its assignments. Then make sure it installs on a test device.
  6. The publish result is saved in the publish-result artifact, 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)