Skip to content

Common tasks

Step-by-step guides you can copy and adapt.

Tip: Run napt <command> --help for options and examples, e.g. napt discover --help.

Initialize a new NAPT project

Quick setup

# Create and enter project directory
mkdir my-intune-packages
cd my-intune-packages

# Initialize NAPT project structure
napt init

Output:

$ napt init
Initializing NAPT project in: /path/to/my-intune-packages

[1/2] Creating directory structure...
[2/2] Creating configuration files...

======================================================================
INITIALIZATION RESULTS
======================================================================
Project Root:    /path/to/my-intune-packages

Created (4):
  [OK] recipes/
  [OK] defaults/vendors/
  [OK] state/deployment/
  [OK] defaults/org.yaml

======================================================================

[SUCCESS] Project initialized!

What gets created

my-intune-packages/
├── defaults/
│   ├── org.yaml              # Organization-wide defaults (commented template)
│   └── vendors/              # Vendor-specific overrides (empty)
├── recipes/                  # Your recipe files go here
└── state/
    └── deployment/           # Per-app deployment state (written by discover, upload, promote)

Handling existing files

NAPT safely skips existing files by default:

$ napt init
Initializing NAPT project in: /path/to/existing-project

[1/2] Creating directory structure...
[2/2] Creating configuration files...

======================================================================
INITIALIZATION RESULTS
======================================================================
Project Root:    /path/to/existing-project

Skipped (4):
  [SKIP] recipes/
  [SKIP] defaults/vendors/
  [SKIP] state/deployment/
  [SKIP] defaults/org.yaml

======================================================================

Note: Existing files were preserved. Use --force to overwrite.

[SUCCESS] Project initialized!

To overwrite existing files (with automatic backup):

napt init --force

This backs up defaults/org.yaml before replacing it (directories are never touched):

$ napt init --force
Initializing NAPT project in: /path/to/existing-project

[1/2] Creating directory structure...
[2/2] Creating configuration files...

======================================================================
INITIALIZATION RESULTS
======================================================================
Project Root:    /path/to/existing-project

Created (1):
  [OK] defaults/org.yaml

Backed Up (1):
  [OK] defaults/org.yaml -> org.yaml.backup

Skipped (3):
  [SKIP] recipes/
  [SKIP] defaults/vendors/
  [SKIP] state/deployment/

======================================================================

[SUCCESS] Project initialized!

Next steps after init

  1. Edit organization defaults (optional):

    # Uncomment and customize settings in defaults/org.yaml
    code defaults/org.yaml
    

  2. Create your first recipe:

    mkdir recipes/Google
    code recipes/Google/chrome.yaml
    

  3. Validate and test:

    napt validate recipes/Google/chrome.yaml
    napt discover recipes/Google/chrome.yaml --verbose
    

Create a recipe for a GitHub release app

Use this when the application is hosted on GitHub with releases.

Example: Git for Windows

  1. Create the recipe file:
# recipes/Git/git.yaml
apiVersion: napt/v1

name: "Git for Windows"
id: "napt-git"

discovery:
  strategy: api_github
  repo: "git-for-windows/git"
  asset_pattern: "Git-.*-64-bit\\.exe$"
  version_pattern: "v?([0-9.]+)\\.windows"

intune:
  detection:
    display_name: "Git"
    architecture: "x64"

psadt:
  app_vars:
    AppName: "Git for Windows"
    AppVersion: "{{discovered_version}}"
  install: |
    Start-ADTProcess -FilePath "Git-{{discovered_version}}-64-bit.exe" -ArgumentList "/VERYSILENT /NORESTART"
  uninstall: |
    Uninstall-ADTApplication -Name "Git"
  1. Validate the recipe:
napt validate recipes/Git/git.yaml
  1. Test discovery:
napt discover recipes/Git/git.yaml --verbose

What to customize: - repo: GitHub repository (owner/repo format) - asset_pattern: Regex to match the installer filename - version_pattern: Regex to extract version from tag - install/uninstall: PowerShell deployment scripts

Create a recipe for a vendor download page

Use this when the vendor has a download page listing installers (no API available).

Example: 7-Zip

  1. Create the recipe file:
# recipes/7-Zip/7zip-x64-msi.yaml
apiVersion: napt/v1

name: "7-Zip (x64) MSI"
id: "napt-7zip-x64-msi"

discovery:
  strategy: web_scrape
  page_url: "https://www.7-zip.org/download.html"
  link_selector: 'a[href$="-x64.msi"]'
  version_pattern: "7z(\\d{2})(\\d{2})-x64"
  version_format: "{0}.{1}"

intune:
  detection:
    display_name: "7-Zip * (x64 edition)"  # Wildcard matches any 7-Zip x64 version
    override_msi_display_name: true         # Override MSI ProductName which includes version

psadt:
  app_vars:
    AppName: "7-Zip"
    AppVersion: "{{discovered_version}}"

Install/uninstall commands are auto-generated for MSI:

  • No psadt.install / psadt.uninstall needed - NAPT generates Start-ADTMsiProcess -Action Install with the exact downloaded filename (plus ALLUSERS=1 for system deployments) and Uninstall-ADTApplication matching the MSI's ProductName exactly
  • Uninstall survives ProductCode changes - Matching is by name, not ProductCode, and the name is re-extracted from each downloaded MSI
  • Custom commands - Set psadt.override_msi_commands: true and provide your own install/uninstall (e.g., for MST transforms or extra MSI properties)

  • Validate and test:

napt validate recipes/7-Zip/7zip-x64-msi.yaml
napt discover recipes/7-Zip/7zip-x64-msi.yaml --verbose

What to customize:

  • page_url: Vendor download page URL
  • link_selector: CSS selector to find the download link
  • version_pattern: Regex to extract version from URL
  • version_format: Format string to transform version (optional)
  • intune.detection: Configure when vendor includes version in DisplayName (e.g., "7-Zip 25.01")
  • display_name: Pattern with wildcards to match the installed app name
  • override_msi_display_name: Set to true to override MSI's versioned DisplayName

Create a recipe for a JSON API endpoint

Use this when the vendor provides a JSON API with version and download URL.

Example: Generic JSON API

  1. Create the recipe file:
# recipes/Vendor/app.yaml
apiVersion: napt/v1

name: "Application Name"
id: "napt-app"

discovery:
  strategy: api_json
  api_url: "https://api.vendor.com/latest"
  version_path: "version"          # JSONPath to version field (e.g., "version" or "data.version")
  download_url_path: "download_url"
  version_pattern: "v?([0-9.]+)"   # Optional: narrow the version value with a regex
  headers:                         # Optional HTTP headers (e.g., for authentication)
    Authorization: "Bearer ${API_TOKEN}"

psadt:
  app_vars:
    AppName: "Application Name"
    AppVersion: "{{discovered_version}}"
  install: |
    Start-ADTProcess -FilePath "{{installer_filename}}" -ArgumentList "/S"
  uninstall: |
    Uninstall-ADTApplication -Name "Application Name"
  1. Set the token in your environment (see Handle authentication tokens).

  2. Validate and test:

napt validate recipes/Vendor/app.yaml
napt discover recipes/Vendor/app.yaml --verbose

What to customize:

  • api_url: JSON API endpoint URL
  • version_path: JSONPath to version field (e.g., "version" or "data.version")
  • download_url_path: JSONPath to download URL field
  • headers: Optional authentication headers

Create a recipe for an MSIX installer

Use this when the application distributes an .msix installer. NAPT extracts metadata from AppxManifest.xml and auto-generates install/uninstall commands.

Example: Slack (MSIX via JSON API)

  1. Create the recipe file:
# recipes/Slack/slack.yaml
apiVersion: napt/v1

name: "Slack"
id: "napt-slack"

discovery:
  strategy: api_json
  api_url: "https://slack.com/api/desktop.latestRelease?arch=x64&variant=msix&redirect=false"
  version_path: "version"
  download_url_path: "url"

# No psadt.install or psadt.uninstall needed: NAPT generates them from the manifest

psadt:
  app_vars:
    AppName: "Slack"
    AppVersion: "{{discovered_version}}"
  1. Validate and test:
napt validate recipes/Slack/slack.yaml
napt discover recipes/Slack/slack.yaml --verbose

What makes MSIX different:

  • No psadt.install / psadt.uninstall needed - NAPT auto-generates commands from the MSIX manifest based on intune.run_as_account
  • No intune.detection needed - Detection queries the AppX package database by identity name (not registry scanning); the store queried matches intune.run_as_account
  • Architecture auto-detected - Extracted from ProcessorArchitecture in the MSIX manifest
  • RequireAdmin auto-defaulted - Defaults to false for run_as_account: "user" since per-user installs don't require elevation

Choosing install scope:

Use intune.run_as_account to control whether the install is provisioned for all users or installed for the current user only:

intune:
  run_as_account: "system"  # Default: provisioned (all users)
  # run_as_account: "user"  # Per-user install

Overriding auto-generated commands:

Only needed for non-standard cases such as license files. Set override_msix_commands: true:

psadt:
  override_msix_commands: true
  install: |
    Add-AppxProvisionedPackage -Online -PackagePath "$($adtSession.DirFiles)\app.msix" -LicensePath "$($adtSession.DirFiles)\license.xml" -SkipLicense
  uninstall: |
    Get-AppxProvisionedPackage -Online | Where-Object { $_.DisplayName -eq "Vendor.App" } | Remove-AppxProvisionedPackage -Online

Create a recipe for a fixed download URL

Use this when the vendor has a stable download URL (like Chrome enterprise MSI).

Example: Google Chrome

  1. Create the recipe file:
# recipes/Google/chrome.yaml
apiVersion: napt/v1

name: "Google Chrome"
id: "napt-chrome"

discovery:
  strategy: url_download
  url: "https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise64.msi"

psadt:
  app_vars:
    AppName: "Google Chrome"
    AppVersion: "{{discovered_version}}"
  1. Validate and test:
napt validate recipes/Google/chrome.yaml
napt discover recipes/Google/chrome.yaml --verbose

What to customize:

  • url: Direct download URL (must be stable, not version-specific)
  • app_vars: Application name and other PSADT variables

Note: MSI installers supply their own version and install/uninstall commands; see the 7-Zip example above.

Handle authentication tokens

Many discovery APIs require a token.

  1. Set token in environment:

    # Set environment variable on Windows:
    $env:API_TOKEN="your-token-here"
    
    # Set environment variable on Linux/macOS:
    export API_TOKEN="your-token-here"
    

  2. Reference in recipe:

    discovery:
      strategy: api_json
      api_url: "https://api.vendor.com/latest"
      headers:
        Authorization: "Bearer ${API_TOKEN}"
    

  3. In CI/CD, use secrets:

    # GitHub Actions
    - name: Discover version
      env:
        API_TOKEN: ${{ secrets.API_TOKEN }}
      run: napt discover recipes/Vendor/app.yaml
    

Recipe-level tokens (less secure)

If you must store tokens in recipes (not recommended for production):

discovery:
  strategy: api_github
  repo: "owner/repo"
  token: "ghp_your_token_here"  # Not recommended - use env vars instead

Test recipes before production

Run this before shipping a new or edited recipe.

  1. Syntax validation:

    napt validate recipes/Vendor/app.yaml
    

  2. Test discovery:

    napt discover recipes/Vendor/app.yaml --verbose
    

  3. Verify downloaded file:

    # Check file exists and has content
    ls -lh downloads/
    

  4. Test build:

    napt build recipes/Vendor/app.yaml --verbose
    

  5. Verify build structure:

    # Check PSADT files are present
    ls builds/napt-app/*/packagefiles/Invoke-AppDeployToolkit.ps1
    

  6. Test packaging:

    napt package recipes/Vendor/app.yaml --verbose
    

  7. Verify .intunewin file:

    # Check versioned package directory was created
    ls -lh packages/napt-app/
    

Deploy to Intune

Upload a packaged app to Microsoft Intune. Requires napt package to have run first.

App registration setup (one time per organization)

Fastest path, as at least an Application Administrator:

napt auth setup --tenant-id "<Directory (tenant) ID>"            # portal-free

# Also trust a CI platform through OIDC (GitHub Actions, main branch shown):
napt auth setup --tenant-id "<Directory (tenant) ID>" \
  --federated-issuer https://token.actions.githubusercontent.com \
  --federated-subject repo:owner/intune-apps:ref:refs/heads/main

To do it by hand, follow App Registration Setup in the user guide (or run napt auth setup ... --print-only for the checklist).

Developer setup (one time)

Sign in once with the IDs from the app registration:

napt auth login --tenant-id "<Directory (tenant) ID>" --client-id "<Application (client) ID>"

On Windows the OS account picker opens; elsewhere your browser does. The IDs are remembered, so later sessions are just napt auth login, and napt upload / napt promote run silently until the session expires. napt auth status shows the account, tenant, and permissions in use; napt auth logout clears the session.

CI/CD setup (one time)

Prefer OIDC federation when your platform supports it (GitHub Actions does): add a federated credential to the app registration and let azure/login mint the token, so there is no secret to store or rotate. See App registration setup.

# GitHub Actions example; the federated credential is scoped to the
# "intune" environment, so only jobs that declare it receive tokens
permissions:
  id-token: write
  contents: read

jobs:
  upload:
    runs-on: ubuntu-latest
    environment: intune
    steps:
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          allow-no-subscriptions: true
      - name: Upload to Intune
        run: napt upload recipes/Google/chrome.yaml

Otherwise create a client secret (Certificates & secrets > New client secret), store the client ID, tenant ID, and secret as pipeline secrets, and pass them as environment variables:

- name: Upload to Intune
  env:
    AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
    AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
    AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
  run: napt upload recipes/Google/chrome.yaml

The app registration must have the DeviceManagementApps.ReadWrite.All and Group.Read.All Microsoft Graph application permissions.

Upload an app

napt upload recipes/Google/chrome.yaml

Example output:

$ napt upload recipes/Google/chrome.yaml
Uploading package for recipe: /path/to/recipes/Google/chrome.yaml

[1/9] Locating .intunewin package...
[2/9] Authenticating with Azure...
[3/9] Parsing package metadata...
[4/9] Creating app record for 'Google Chrome'...
[UPLOAD] Created Intune app: <app id>
[5/9] Uploading to Azure Blob Storage...
[6/9] Committing content version...
[7/9] Creating app record for '[Update] Google Chrome'...
[UPLOAD] Created Intune app: <update id>
[8/9] Uploading to Azure Blob Storage...
[9/9] Committing content version...
======================================================================
UPLOAD RESULTS
======================================================================
App ID:          napt-chrome
App Name:        Google Chrome
Version:         <version>
Intune Win32 App ID:    <app id>
Intune Win32 Update ID: <update id>
Package:         /path/to/packages/napt-chrome/<version>/Invoke-AppDeployToolkit.intunewin
Status:          success
======================================================================

[SUCCESS] Package uploaded to Intune successfully!

With the default intune.build_types: "both", the install entry and the [Update] entry are each created, uploaded, and committed (nine steps). app_only or update_only runs six.

Full pipeline example

# 1. Check for new version (skips download if unchanged)
napt discover recipes/Google/chrome.yaml

# 2. Build PSADT package
napt build recipes/Google/chrome.yaml

# 3. Create .intunewin package
napt package recipes/Google/chrome.yaml

# 4. Upload to Intune
napt upload recipes/Google/chrome.yaml

Override publisher and description

By default, the publisher is inferred from the vendor directory name (e.g., recipes/Google/"Google"). Override per-recipe with the intune: section:

apiVersion: napt/v1

name: "Google Chrome"
id: "napt-chrome"

discovery:
  strategy: url_download
  url: "https://dl.google.com/..."

intune:
  publisher: "Google LLC"
  description: "Google Chrome browser for enterprise deployment."
  privacy_url: "https://policies.google.com/privacy"
  info_url: "https://chromeenterprise.google"

psadt:
  # ... rest of recipe

Override upload behavior

Control how Intune handles installation, restarts, and script execution per-recipe using the intune: section. All fields have defaults and can also be set in defaults/org.yaml for org-wide policy.

intune:
  # Run installer and scripts as the logged-in user instead of SYSTEM.
  # Required for apps that install into the user profile. Default: "system".
  run_as_account: "user"

  # Suppress any device restart after install (useful for background updates).
  # Default: "basedOnReturnCode". Allowed: allow, suppress, force, basedOnReturnCode.
  device_restart_behavior: "suppress"

  # Increase timeout for large or slow installers. Default: 60.
  max_run_time_minutes: 120

  # Feature the app in Company Portal. Default: false.
  is_featured: true

  # Prevent self-service uninstall from Company Portal. Default: true.
  allow_available_uninstall: false

  # Require scripts to be code-signed before Intune will run them. Default: false.
  enforce_signature_check: true

  # Run installer and scripts in a 32-bit PowerShell context. Default: false.
  run_as_32_bit: true

See recipe-reference.md for all allowed values.

Require recorded releases before upload

For review-gated publish workflows, make napt upload refuse anything that was not recorded at discovery. Set once in defaults/org.yaml:

deployment:
  require_pending: true

For a legitimate manual upload under this policy, run napt discover first. See require_pending for exact behavior.

Promote updates through rings

Roll updates out gradually: pilot devices first, everyone else after the release has proven itself.

  1. Define rings once in defaults/org.yaml (groups are Entra ID display names or object IDs):
deployment:
  rings:
    - name: "pilot"
      groups: ["Pilot Devices"]
      promote_after_days: 2
    - name: "production"
      groups: ["Production Devices"]
  install:
    intent: "available"
    groups: ["All Users"]
  1. Plan: compute what is eligible (read-only):
napt promote plan

A newly uploaded release starts its rollout in the first ring; a release that has held its ring for promote_after_days is promoted to the next. The same plan also points new installs (the install entry) at the new release, so net-new devices get it as soon as the first ring does. Eligible actions are written per app to state/plans/<app-id>.json; review the files, or commit them and gate the apply on a pull request. Every action opens with a plain-English summary sentence and carries the details behind it (the release, the groups it will assign, the version it displaces, and for a promotion out of a held ring, when the release entered it and the ring's bake threshold) so the files read as the review record.

  1. Apply: execute the plan against Intune:
napt promote apply

Ring groups are assigned to the release's [Update] entry as required installs; the displaced older release is unassigned and retired per deployment.retain_versions. Each app's plan file is consumed after that app applies fully, and one app's failure keeps its plan file for retry without blocking the rest. Every apply also prints a drift check: discrepancies between deployment state and Intune (removed assignments, admin-made changes, stray apps) are warned about, never corrected. Use napt promote plan --check-drift for the same report without applying anything. Both commands also fail fast on a group typo or deleted Entra ID group: an authenticated plan refuses to write plans that name an unresolvable group, and apply checks every group an app's plan is about to assign before touching that app, so a bad group fails that app with zero changes instead of a half-applied plan. Run napt status to see where every app stands.

Run plan and apply on a schedule and promotion becomes automatic: each release baked long enough moves one ring further on the next run. A ring without promote_after_days is a manual gate; releases hold it until you change the configuration.

Set a custom app icon

napt build extracts an icon from the installer to icons/{id}.png automatically, and napt upload sends it to Intune. Most apps need no configuration.

When extraction finds no usable icon (the build prints a warning), or you want a different image, you have two options:

Option 1: Set logo_path in the recipe (recommended)

intune:
  # Relative paths resolve from the recipe file's location
  logo_path: "assets/7zip-logo.png"

The icon file lives in the recipe repo, so the fix travels to every machine. logo_path always wins over the icons directory and disables extraction for that recipe. Use a 256x256 PNG or JPEG under 700KB for best results in Company Portal.

Option 2: Drop a PNG into the icons directory

# The file name must match the recipe id
cp my-better-icon.png icons/napt-7zip-x64-msi.png

NAPT never overwrites a file in icons/, so this survives future builds on this machine but does not travel with the repo (the directory is gitignored). Delete the file and rebuild to force re-extraction.

Fix a broken published app

You published an app, installs are failing: a bad install command, wrong detection settings, a missing PSADT step. You fixed the recipe, and now Intune needs to match.

The fix: delete the broken app and publish fresh.

Intune throttles retries after repeated failures (the Global Retry Schedule), and republishing content to the same app does not reliably reset it. A fresh app object gets a clean evaluation on every device:

  1. Delete the broken app entries (install and [Update]) in the Intune portal. Deleting first matters: NAPT recognizes its own apps by their provenance stamp, so re-running upload against the existing broken app would adopt it instead of creating a new one.
  2. Rebuild and upload:
    napt build recipes/Vendor/app.yaml
    napt package recipes/Vendor/app.yaml
    napt upload recipes/Vendor/app.yaml
    
    Upload finds no stamped apps, creates fresh entries, and records the new app IDs in deployment state automatically.
  3. Recreate the assignments the old app had.

If the vendor has shipped a newer version since the broken publish, you can also just run the normal pipeline; the new release creates new app entries anyway, and the broken version's entries can be deleted.

Exception: no device has attempted the install yet.

If you caught the problem before any assignment took effect (the app is still unassigned, or you spotted a wrong command during portal review), there is no retry throttling to escape, and an in-place fix is faster:

napt build recipes/Vendor/app.yaml
napt package recipes/Vendor/app.yaml
napt upload recipes/Vendor/app.yaml --force

--force updates the existing app entries in place (metadata and a fresh content version together) and keeps the app IDs. It never creates duplicates.

Automate NAPT with GitHub Actions

NAPT performs no git or CI operations itself: it reads and writes deterministic files and leaves the choreography to your pipeline. What follows is a review-gated flow on GitHub Actions. Adapt names, schedules, and branch rules to your org.

The model. Two PR streams gate everything:

  • Publish PRs (one per app): napt discover records a pending release in state/deployment/<id>.json; CI opens a per-app PR with that diff. The title carries the decision (Publish Google Chrome 140.0.7339.128) and the body is a generated fact sheet: version, currently published version, installer URL, SHA-256, what merging does, and how to hold or reject. Merging approves the release: a workflow builds, packages, and uploads it, with the hash gate guaranteeing the approved binary is exactly what ships.
  • Promotion PRs (one, batched): napt promote plan writes one state/plans/<id>.json file per app with that app's eligible promotions; CI commits them to a branch and opens a PR whose body opens with a risk line (**This plan:** 2 to pilot, 1 to production), lists every app's plan summaries and the run's drift warnings, and carries a promotes-to-production label when the final ring is targeted. Merging approves the promotions: a workflow runs napt promote apply, which executes each app's plan as an allowlist, independently. To hold one app's promotions, delete its plan file in the PR; the next scheduled plan will re-propose it, and the other apps merge and apply unaffected.

Writeback commits. After upload and apply, NAPT has recorded new facts (Intune app IDs, ring positions) in the working tree that must reach main, so those workflows push a [skip ci] commit. Two consequences to set up once:

  • The workflow identity needs permission to push to main, either allow the Actions bot through branch protection or use a bot/app token with bypass rights.
  • [skip ci] keeps the writeback from re-triggering workflows.

Secrets. AZURE_CLIENT_ID and AZURE_TENANT_ID for the app registration, plus either a federated credential (OIDC, recommended; swap the env: block in the examples below for an azure/login step) or AZURE_CLIENT_SECRET (see App Registration Setup).

Recommended org.yaml hardening:

deployment:
  require_pending: true   # nothing reaches Intune without a reviewed release

Workflow 1: discover (opens publish PRs)

name: discover
on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:
permissions:
  contents: write
  pull-requests: write
jobs:
  discover:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install napt
      - name: Restore installers from the last run
        uses: actions/cache/restore@v4
        with:
          path: downloads
          key: installers-
          restore-keys: installers-
      - name: Discover all recipes
        shell: bash
        # Actions' `shell: bash` runs with -e, so an unguarded loop would
        # end at the first failing recipe (a vendor outage, a version the
        # pattern cannot handle) and silently skip every recipe after it.
        # Failures are collected instead, the successful apps still get
        # their PRs below, and the last step fails the job.
        run: |
          : > discover-failures.txt
          git ls-files 'recipes/*.yaml' 'recipes/**/*.yaml' | while read -r recipe; do
            napt discover "$recipe" || {
              echo "::error::napt discover failed for $recipe"
              echo "$recipe" >> discover-failures.txt
            }
          done
      - name: Cache installers for publish and the next discover
        uses: actions/cache/save@v4
        with:
          path: downloads
          key: installers-${{ github.run_id }}
      - name: Open one PR per app with a new pending release
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          git config user.name "napt-bot"
          git config user.email "napt-bot@users.noreply.github.com"
          # Stage first so brand-new (untracked) state files are seen too
          git add state/deployment
          changed=$(git diff --cached --name-only -- state/deployment)
          [ -z "$changed" ] && exit 0
          # Writes pr-body.md and prints the PR title for one app's
          # state file. The title is the decision (imperative, display
          # name + version); the body layers facts, then what merging
          # does, then how to say no.
          pr_meta() {
          python - "$1" <<'PY'
          import json
          import subprocess
          import sys
          from pathlib import Path

          state_path = Path(sys.argv[1])
          state = json.loads(state_path.read_text(encoding="utf-8"))
          # The state file names its app; the filename backstops a
          # file saved before any name was recorded.
          name = state.get("name") or state_path.stem
          pending = state.get("pending")
          published = state.get("published") or {}
          if pending is None:
              # Discovery cleared the pending slot: the vendor serves
              # the already-published release. The diff only records it.
              Path("pr-body.md").write_text(
                  f"**Name:** {name}\n\n"
                  "Discovery found the vendor serving the already-"
                  "published release, so this diff only clears the "
                  "app's pending slot. Merging records that; nothing "
                  "is published.\n",
                  encoding="utf-8",
              )
              print(f"Clear pending release for {name}")
              raise SystemExit
          current = published.get("version") or "none - first deployment"
          # Ask napt whether the new version is lower than the published
          # one. It orders versions exactly as the detection script on a
          # device does, so the workflow never carries its own copy.
          status = json.loads(
              subprocess.run(
                  ["napt", "status", "--format", "json"],
                  capture_output=True, text=True, check=True,
              ).stdout
          )
          row = next(r for r in status if r["app_id"] == state_path.stem)
          warning = suffix = ""
          if row["pending_is_downgrade"]:
              suffix = f" (downgrade from {current})"
              warning = (
                  f"**This is a downgrade:** {pending['version']} is "
                  f"lower than the published {current}. Devices already "
                  f"on {current} will not move to it; merging changes "
                  "what new installs get.\n\n"
              )
          Path("pr-body.md").write_text(
              f"{warning}"
              f"**Name:** {name}\n"
              f"**New version:** {pending['version']}\n"
              f"**Currently published:** {current}\n"
              f"**Installer:** {pending['url']}\n"
              f"**SHA-256:** `{pending['sha256']}`\n"
              "\n"
              "**Merging approves this exact binary.** The publish "
              "workflow builds, packages, and uploads it; the upload "
              "hash gate refuses any file that does not match the "
              "SHA-256 above.\n"
              "\n"
              "**To hold:** leave this PR open - nothing ships until "
              "it merges.\n"
              "**Closing is not a durable rejection:** the next "
              "discover run re-proposes the release, and a newer "
              "vendor release replaces this PR's content "
              "automatically.\n",
              encoding="utf-8",
          )
          print(f"Publish {name} {pending['version']}{suffix}")
          PY
          }
          # Snapshot all changes on a temp branch, then carve out one
          # branch per app so each PR reviews exactly one state file.
          git checkout -b napt/discover-snapshot
          git commit -m "temp: discovery snapshot"
          for f in $changed; do
            app=$(basename "$f" .json)
            git checkout -B "napt/discover-$app" origin/main
            git checkout napt/discover-snapshot -- "$f"
            git commit -m "feat: Record pending release for $app"
            git push -f origin "napt/discover-$app"
            title=$(pr_meta "$f")
            # Refresh the title and body on every force-push so a
            # superseding release never leaves a stale decision open.
            gh pr create --head "napt/discover-$app" \
              --title "$title" --body-file pr-body.md \
              || gh pr edit "napt/discover-$app" \
                --title "$title" --body-file pr-body.md
          done
      - name: Fail the run if any recipe failed to discover
        shell: bash
        run: |
          [ -s discover-failures.txt ] || exit 0
          echo "Discovery failed for:"
          cat discover-failures.txt
          exit 1

One recipe's failure does not hold up the others: the discover step records it, the PR step still opens publish PRs for every app that did discover a new release, and the final step fails the run so the failure is not missed. Each failed recipe is also annotated on the run summary.

Workflow 2: publish (on merge of a publish PR)

name: publish
on:
  push:
    branches: [main]
    paths: ["state/deployment/**"]
# Serializes publish runs (bursts of merges dedupe to the newest run).
# Deliberately NOT shared with promote-apply: a merge that touches both
# deployment state and the plan file triggers both workflows, and runs
# sharing a group cancel each other instead of queueing.
concurrency: napt-publish
permissions:
  contents: write
jobs:
  publish:
    runs-on: windows-latest
    env:
      AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
      AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install napt
      - name: Restore cached installers
        uses: actions/cache/restore@v4
        with:
          path: downloads
          key: installers-
          restore-keys: installers-
      - name: Publish every app with an approved pending release
        shell: bash
        run: |
          git ls-files 'recipes/*.yaml' 'recipes/**/*.yaml' | while read -r recipe; do
            id=$(python -c "import sys, yaml; print(yaml.safe_load(open(sys.argv[1], encoding='utf-8'))['id'])" "$recipe")
            state="state/deployment/$id.json"
            [ -f "$state" ] || continue
            pending=$(python -c "import json, sys; print(json.load(open(sys.argv[1], encoding='utf-8')).get('pending') is not None)" "$state")
            [ "$pending" = "True" ] || continue
            # Use the cached installer when its hash matches the approved
            # release; otherwise fetch from the vendor. --stateless keeps
            # the approved pending untouched, and napt build refuses any
            # file that does not match it. Downloads are filed by version
            # (downloads/<id>/<version>/<file>), hence the two-level glob.
            psha=$(python -c "import json, sys; print(json.load(open(sys.argv[1], encoding='utf-8'))['pending']['sha256'])" "$state")
            if ! sha256sum "downloads/$id/"*/* 2>/dev/null | grep -q "^$psha "; then
              napt discover "$recipe" --stateless
              # Fail fast when the vendor no longer serves the approved
              # binary (napt build would refuse it anyway).
              sha256sum "downloads/$id/"*/* 2>/dev/null | grep -q "^$psha " || {
                echo "::error::$id: vendor no longer serves the approved release ($psha); the approval is stranded until a new discover PR supersedes it"
                exit 1
              }
            fi
            napt build "$recipe"
            napt package "$recipe"
            napt upload "$recipe"
          done
      - name: Write back recorded app IDs
        shell: bash
        run: |
          git config user.name "napt-bot"
          git config user.email "napt-bot@users.noreply.github.com"
          git add state/deployment
          git diff --cached --quiet && exit 0
          git commit -m "chore: Record published releases [skip ci]"
          # main may have advanced while this run published (more merges,
          # another workflow's writeback). State files are per-app, so a
          # rebase cannot conflict.
          for attempt in 1 2 3; do
            git push && exit 0
            git pull --rebase origin main
          done
          git push

Why the installer cache steps matter. Without them, the publish runner re-downloads from the vendor, which couples an already-approved publish to the vendor still serving that exact binary; a pulled or replaced file strands the approval at the hash gate. The cache steps above hand the publish runner the very binary that was reviewed; the sha256 check in the loop falls back to a fresh download when the cache is stale or evicted (GitHub evicts caches unused for about a week), so the flow degrades gracefully. This is safe by construction: the upload hash gate validates whatever binary the runner provides, so a cache can never ship the wrong bytes.

The restore step in the discover workflow serves a second purpose: bandwidth. napt discover skips the download when the installer has not changed; Skipping downloads explains how. Everything it consults lives in downloads/, and a fresh runner starts without it, so restoring downloads/ from the last run is what lets the scheduled discover skip re-downloading installers that have not changed, which adds up quickly for recipe sets full of large installers. Keep the path values of the save and restore steps identical: actions/cache makes the path list part of the cache version, so a mismatch reads as a silent cache miss.

For long-lived archival (including installers for retained releases the vendor no longer serves) replace the cache steps with an object store (S3, Azure Blob) or a self-hosted runner with a persistent downloads/ directory.

Workflow 3: promotion plan (opens the promotion PR)

name: promote-plan
on:
  schedule:
    - cron: "0 7 * * *"
  workflow_dispatch:
permissions:
  contents: write
  pull-requests: write
jobs:
  plan:
    runs-on: windows-latest
    env:
      AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
      AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install napt
      - name: Plan promotions (with drift report and writeback recovery)
        shell: bash
        # tee keeps the log so the PR body below can carry the drift
        # warnings. Actions' `shell: bash` runs with -eo pipefail, so
        # napt's exit code survives the pipe.
        run: napt promote plan --check-drift --reconcile | tee plan.log
      - name: Open or update the promotion PR
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          # git status sees untracked files (a first-ever plan) too.
          # Watch all of state/: --reconcile may have repaired a
          # deployment state file whose publish writeback was lost.
          [ -z "$(git status --porcelain -- state)" ] && exit 0
          git config user.name "napt-bot"
          git config user.email "napt-bot@users.noreply.github.com"
          git checkout -B napt/promote-plan origin/main
          git add state
          git commit -m "feat: Plan ring promotions"
          git push -f origin napt/promote-plan
          # Writes pr-body.md from the plan files themselves: the risk
          # line first, then each app's action summaries (the same
          # sentences NAPT wrote into the files), the hold instruction,
          # and the plan run's drift warnings. Prints "production" when
          # the plan assigns the final ring (ring policy is org-wide,
          # so the last ring comes from org.yaml).
          label=$(python - <<'PY'
          import json
          from pathlib import Path

          import yaml

          org = yaml.safe_load(
              Path("defaults/org.yaml").read_text(encoding="utf-8")
          ) or {}
          rings = [r["name"] for r in org.get("deployment", {}).get("rings", [])]
          last_ring = rings[-1] if rings else None

          counts = {}
          assigns = 0
          stanzas = []
          final_ring_hit = False
          for path in sorted(Path("state/plans").glob("*.json")):
              plan = json.loads(path.read_text(encoding="utf-8"))
              lines = [f"**{plan['name']}** (`{plan['app_id']}`)"]
              for action in plan["actions"]:
                  lines.append(f"- {action['summary']}")
                  if action["type"] == "promote":
                      counts[action["ring"]] = counts.get(action["ring"], 0) + 1
                      if action["ring"] == last_ring:
                          final_ring_hit = True
                  else:
                      assigns += 1
              stanzas.append("\n".join(lines))

          ordered = [r for r in rings if r in counts]
          ordered += [r for r in counts if r not in rings]
          bits = [f"{counts[r]} to {r}" for r in ordered]
          if assigns:
              bits.append(f"{assigns} install assignment(s)")
          risk = ", ".join(bits) if bits else (
              "no ring changes - this refresh carries recovered "
              "deployment state only"
          )

          drift = []
          in_section = False
          for line in Path("plan.log").read_text(encoding="utf-8").splitlines():
              if "DRIFT CHECK" in line:
                  in_section = True
              elif in_section and "[WARNING]" in line:
                  drift.append("- " + line.split("[WARNING]", 1)[1].strip())

          parts = [f"**This plan:** {risk}"]
          if stanzas:
              parts.extend(stanzas)
              parts.append(
                  "**Merging approves and applies every action above.**"
              )
              parts.append(
                  "**To hold one app:** delete its "
                  "`state/plans/<app-id>.json` file from this PR - the "
                  "other apps apply unaffected, and the next plan run "
                  "re-proposes whatever is still eligible."
              )
          if drift:
              parts.append(
                  "**Drift warnings** (deployment state vs. the tenant "
                  "at plan time):\n" + "\n".join(drift)
              )
          Path("pr-body.md").write_text(
              "\n\n".join(parts) + "\n", encoding="utf-8"
          )
          print("production" if final_ring_hit else "")
          PY
          )
          # Title stays generic and stable (only one promotion PR is
          # ever open; title churn breaks email threading) - the risk
          # signal lives in the body's first line and the label.
          gh pr create --head napt/promote-plan \
            --title "Promotion plan" --body-file pr-body.md \
            || gh pr edit napt/promote-plan --body-file pr-body.md
          if [ "$label" = "production" ]; then
            gh label create promotes-to-production \
              --description "This plan assigns the final ring" \
              --color D93F0B --force
            gh pr edit napt/promote-plan --add-label promotes-to-production
          else
            gh pr edit napt/promote-plan \
              --remove-label promotes-to-production || true
          fi

Workflow 4: promotion apply (on merge of the promotion PR)

name: promote-apply
on:
  push:
    branches: [main]
    paths: ["state/plans/**"]
# Own group (not shared with publish) - see the publish workflow's
# concurrency comment.
concurrency: napt-promote-apply
permissions:
  contents: write
jobs:
  apply:
    runs-on: windows-latest
    env:
      AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
      AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install napt
      # Apply exits 1 when any app's plan fails, but other apps may
      # have applied; continue so the writeback records their ring
      # positions and consumed plans; a final step fails the run.
      - name: Apply the approved plans
        id: apply
        continue-on-error: true
        run: napt promote apply
      - name: Write back ring positions and consume the plans
        shell: bash
        run: |
          git config user.name "napt-bot"
          git config user.email "napt-bot@users.noreply.github.com"
          git add state
          git diff --cached --quiet && exit 0
          git commit -m "chore: Record applied promotions [skip ci]"
          # Same rebase-and-retry as the publish writeback.
          for attempt in 1 2 3; do
            git push && exit 0
            git pull --rebase origin main
          done
          git push
      - name: Surface a failed apply
        if: steps.apply.outcome == 'failure'
        run: exit 1

Notes:

  • Promotions merged but applied later are always safe: bake time only grows, and apply validates every entry against current state anyway.
  • Apply treats each app's plan file as an independent unit: a failure (an unresolvable group, a Graph error) fails that app, keeps its plan file on main for the next apply, and never blocks the other apps, which is why the writeback above runs even when the apply step fails.
  • Resolving a failed app depends on the failure class. A transient Graph error needs no fix: re-run the apply workflow; already-applied actions skip, the rest complete, and the plan file is consumed. An unresolvable group needs the configuration fixed (or the Entra ID group restored) and then a re-plan, not just a re-run: plan files bake in group names at plan time, so the fix reaches Intune when the next scheduled plan regenerates the file and the promotion PR carries the corrected plan. Until then, apply keeps failing that one app, and only that one. A corrupted state file restores from git history like any other committed file.
  • All four workflows are idempotent: re-running any of them converges to the same result (upload adopts existing apps, apply skips already-applied actions).
  • A publish whose writeback push fails (branch protection, a crashed runner) self-heals: the next plan run's --reconcile re-records the publication from tenant evidence, the promotion PR carries the repair, and the recovered release is planned for its first ring in the same run. Re-running the failed publish also converges, just sooner.
  • windows-latest runners are required for napt package (IntuneWinAppUtil.exe is Windows-only). The discover workflow alone could run on Linux with msitools installed, since discover reads the version out of every MSI it downloads.

Share a base recipe between apps

When several recipes differ only in a few fields, put the shared part in one file and name it as the parent of each app recipe.

  1. Write the base recipe. It is a complete recipe; keep it out of the vendor folders so NAPT never runs it on its own:

    # recipes/_base/chromium-family.yaml
    apiVersion: napt/v1
    name: "Chromium base"
    id: "chromium-base"
    discovery:
      strategy: url_download
      url: "https://example.com/placeholder.msi"
    psadt:
      app_vars:
        AppProcessesToClose: ["chrome", "msedge"]
    intune:
      detection:
        exact_match: false
    

  2. Write each app as a child that sets only what differs. Name it <app>.override.yaml:

    # recipes/Google/chrome.override.yaml
    apiVersion: napt/v1
    parent: ../_base/chromium-family.yaml
    name: "Google Chrome"
    id: "napt-chrome"
    discovery:
      url: "https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise64.msi"
    

  3. Validate the child. The output names the parent it merged:

    napt validate recipes/Google/chrome.override.yaml
    

Run every command against the child, never the base. The merge order and list behavior are in Configuration layers.

Update existing recipes

When a recipe needs changes (new version format, different download URL, etc.).

  1. Edit the recipe file.

  2. Validate and test it the same way as a new recipe: see Test recipes before production.

Troubleshoot discovery failures

Common issues and solutions when napt discover fails.

Issue: "Unknown discovery strategy"

Problem: Recipe uses a strategy that doesn't exist or isn't registered.

Solution:

  1. Check strategy name spelling (must be: api_github, api_json, url_download, or web_scrape)

  2. Validate recipe: napt validate recipes/App/app.yaml

  3. Check for typos in strategy configuration

Issue: "Version extraction failed"

Problem: NAPT can't extract version from the downloaded file or API response.

Solution:

  1. Use --debug to see what NAPT is trying to parse:
napt discover recipes/App/app.yaml --debug
  1. For MSI files, verify the file is a valid MSI

  2. For api_json, check that version_path points to the correct JSON field

  3. For web_scrape, verify version_pattern regex matches the URL format

Issue: "GitHub API rate limit"

Problem: Using api_github without authentication hits rate limits.

Solution:

  1. Create a GitHub personal access token
  2. Add to recipe:
    discovery:
      strategy: api_github
      repo: "owner/repo"
      token: "${GITHUB_TOKEN}"
    
  3. Set GITHUB_TOKEN in your environment (see Handle authentication tokens)

Issue: "Download failed" or "Network error"

Problem: Can't download the installer file.

Solution:

  1. Check URL is accessible: curl -I <url> or open in browser

  2. Verify authentication if required (API tokens, headers)

  3. Check network connectivity and firewall rules

  4. Use --verbose to see HTTP request/response details

Issue: discover reuses an installer you want downloaded again

Problem: napt discover reports that the version is already downloaded (or File not modified) and you want a fresh copy.

Solution:

The downloads folder is disposable: delete the app's folder and rediscover.

rm -r downloads/<app_id>
napt discover recipes/app.yaml

Issue: "Deployment state corrupted"

Problem: A file under state/deployment/ has invalid JSON.

Solution:

Deployment state files are authoritative and are never auto-replaced. Fix the JSON or restore the file from version control.

To run discovery once without reading or writing deployment state, use --stateless:

napt discover recipes/app.yaml --stateless

Issue: MSI version extraction fails on Linux/macOS

Problem: napt discover or napt build cannot read the MSI ProductVersion on a non-Windows machine.

Solution: Install msitools, which provides the msiinfo backend:

sudo apt-get install msitools  # Debian/Ubuntu
sudo dnf install msitools      # RHEL/Fedora
brew install msitools          # macOS

What's next?

  • User Guide - Deep dive into discovery strategies, state management, and configuration
  • Creating Recipes - Detailed strategy configuration guides
  • Examples - Browse working recipe examples