Skip to content

upload

napt.upload.manager

Upload orchestrator for NAPT Intune deployment.

Coordinates the full upload pipeline: loading recipe config, inferring the package path, authenticating, parsing the .intunewin file, building app metadata, and executing the Graph API upload flow.

upload_package

upload_package(recipe_path: Path, force: bool = False) -> UploadResult

Upload a packaged app to Microsoft Intune via the Graph API.

Loads the recipe config, infers the .intunewin package path, authenticates using the available Azure credential, parses encryption metadata from the package, and executes the full Graph API upload flow.

When intune.build_types is "both" (the default), two Intune app entries are created: an install entry (detection script only) and an update entry (detection + requirements scripts). Each entry is created, uploaded, and committed in sequence before moving to the next.

The package directory is inferred as packages/{app.id}/{version}/. Run 'napt package' before calling this function.

Authentication needs no configuration file:

  • Developers: run 'napt auth login' once
  • CI/CD: set AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, or use OIDC federation

Before any Graph call, the package's installer hash (from the build manifest) is verified against the pending release recorded in the app's deployment state, so what was recorded at discovery is byte-for-byte what ships. A hash mismatch aborts the upload. When no pending release is recorded, the upload proceeds with a warning — or fails when deployment.require_pending is enabled. On success, the deployment state records the published version, hash, and Intune app IDs, and a matching pending slot is cleared.

Re-running an upload is safe: existing NAPT-stamped apps matching this publish instance (recipe id, entry type, installer hash) are adopted — or their interrupted content upload resumed — instead of duplicated. Adoption keeps the app as it is; it does not re-send metadata or content. Pass force=True to update matched apps' metadata and upload a fresh content version (e.g., after changing PSADT commands or detection settings without a new installer release).

Parameters:

Name Type Description Default
recipe_path Path

Path to the recipe YAML file.

required
force bool

When True, matched stamped apps are re-uploaded (metadata and content) instead of adopted as-is. Never creates duplicates.

False

Returns:

Type Description
UploadResult

Upload result including the Intune app ID(s), app name, version, and package path. intune_app_id is None when build_types is "update_only"; intune_update_app_id is None when build_types is "app_only".

Raises:

Type Description
ConfigError

If the package directory is not found, or detection/ requirements scripts are absent from the package directory. Run 'napt package' to create or recreate the package.

AuthError

If all Azure credential methods fail.

NetworkError

If Graph API or Azure Blob Storage calls fail.

PackagingError

If the .intunewin file is malformed, the package's installer hash does not match the pending release in deployment state, or no pending release is recorded while deployment.require_pending is enabled.

StateError

On a corrupted deployment state file.

Example

Upload and print the resulting Intune app IDs:

from pathlib import Path
from napt.upload.manager import upload_package

result = upload_package(Path("recipes/Google/chrome.yaml"))
print(f"Install app ID: {result.intune_app_id}")
if result.intune_update_app_id:
    print(f"Update app ID: {result.intune_update_app_id}")

Source code in napt/upload/manager.py
def upload_package(recipe_path: Path, force: bool = False) -> UploadResult:
    """Upload a packaged app to Microsoft Intune via the Graph API.

    Loads the recipe config, infers the .intunewin package path, authenticates
    using the available Azure credential, parses encryption metadata from the
    package, and executes the full Graph API upload flow.

    When intune.build_types is "both" (the default), two Intune app entries are
    created: an install entry (detection script only) and an update entry
    (detection + requirements scripts). Each entry is created, uploaded, and
    committed in sequence before moving to the next.

    The package directory is inferred as packages/{app.id}/{version}/.
    Run 'napt package' before calling this function.

    Authentication needs no configuration file:

    - Developers: run 'napt auth login' once
    - CI/CD: set AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, or use
        OIDC federation

    Before any Graph call, the package's installer hash (from the build
    manifest) is verified against the pending release recorded in the app's
    deployment state, so what was recorded at discovery is byte-for-byte
    what ships. A hash mismatch aborts the upload. When no pending release
    is recorded, the upload proceeds with a warning — or fails when
    deployment.require_pending is enabled. On success, the deployment
    state records the published version, hash, and Intune app IDs, and a
    matching pending slot is cleared.

    Re-running an upload is safe: existing NAPT-stamped apps matching this
    publish instance (recipe id, entry type, installer hash) are adopted —
    or their interrupted content upload resumed — instead of duplicated.
    Adoption keeps the app as it is; it does not re-send metadata or
    content. Pass force=True to update matched apps' metadata and upload a
    fresh content version (e.g., after changing PSADT commands or detection
    settings without a new installer release).

    Args:
        recipe_path: Path to the recipe YAML file.
        force: When True, matched stamped apps are re-uploaded (metadata
            and content) instead of adopted as-is. Never creates
            duplicates.

    Returns:
        Upload result including the Intune app ID(s), app name, version, and
            package path. intune_app_id is None when build_types is "update_only";
            intune_update_app_id is None when build_types is "app_only".

    Raises:
        ConfigError: If the package directory is not found, or detection/
            requirements scripts are absent from the package directory.
            Run 'napt package' to create or recreate the package.
        AuthError: If all Azure credential methods fail.
        NetworkError: If Graph API or Azure Blob Storage calls fail.
        PackagingError: If the .intunewin file is malformed, the
            package's installer hash does not match the pending release
            in deployment state, or no pending release is recorded while
            deployment.require_pending is enabled.
        StateError: On a corrupted deployment state file.

    Example:
        Upload and print the resulting Intune app IDs:
            ```python
            from pathlib import Path
            from napt.upload.manager import upload_package

            result = upload_package(Path("recipes/Google/chrome.yaml"))
            print(f"Install app ID: {result.intune_app_id}")
            if result.intune_update_app_id:
                print(f"Update app ID: {result.intune_update_app_id}")
            ```

    """
    logger = get_global_logger()

    config = load_effective_config(recipe_path)
    app_id: str = config["id"]
    app_name: str = config["name"]
    build_types: str = config["intune"]["build_types"]

    logger.verbose("UPLOAD", f"Starting upload for '{app_name}' ({app_id})")
    logger.verbose("UPLOAD", f"build_types: {build_types}")

    # Resolve the app icon once; it is shared by the install and update entries
    large_icon = _resolve_large_icon(config)

    # Step 1: Locate the package directory
    total_steps = 9 if build_types == "both" else 6
    logger.step(1, total_steps, "Locating .intunewin package...")
    packages_dir = Path(config["directories"]["package"])
    package_path, version = _infer_package_dir(packages_dir, app_id)
    logger.verbose("UPLOAD", f"Package: {package_path}")
    logger.verbose("UPLOAD", f"Version: {version}")

    manifest = _read_build_manifest(package_path.parent)
    installer_sha256: str = manifest["installer_sha256"]

    # Verify provenance against deployment state before any Graph call:
    # what was recorded at discovery must be byte-for-byte what ships.
    state_path = deployment_state_path(
        Path(config["directories"]["state"]) / "deployment", app_id
    )
    state = load_deployment_state(state_path)
    pending = state.get("pending")
    if pending:
        if pending.get("sha256") != installer_sha256:
            raise PackagingError(
                f"Installer hash mismatch for '{app_id}': the package was "
                f"built from a different binary than the pending release "
                f"recorded in {state_path}.\n"
                f"  pending:  {pending.get('version')} "
                f"(sha256 {pending.get('sha256')})\n"
                f"  package:  {version} (sha256 {installer_sha256})\n"
                "Re-run 'napt discover', 'napt build', and 'napt package' "
                "so the package matches the recorded release."
            )
        logger.info(
            "UPLOAD", f"Package matches pending release (sha256 {installer_sha256})"
        )
    elif config["deployment"]["require_pending"]:
        raise PackagingError(
            f"No pending release recorded for '{app_id}' and "
            "deployment.require_pending is enabled.\n"
            "Run 'napt discover' to record the release, or add a pending "
            f"entry (version, sha256, url) to {state_path}."
        )
    else:
        logger.warning(
            "UPLOAD",
            f"No pending release recorded for '{app_id}'; uploading "
            "without provenance verification.",
        )

    # Step 2: Authenticate
    logger.step(2, total_steps, "Authenticating with Azure...")
    access_token = get_access_token()

    # Step 3: Parse .intunewin metadata
    logger.step(3, total_steps, "Parsing package metadata...")
    intunewin_metadata = parse_intunewin(package_path)

    # Reconcile-before-act: list existing apps once so stamped apps from a
    # previous (possibly crashed) run are adopted instead of duplicated.
    existing_apps = list_mobile_apps(access_token)
    logger.verbose("UPLOAD", f"Tenant has {len(existing_apps)} mobile apps")

    intune_app_id: str | None = None
    intune_update_app_id: str | None = None

    if build_types in ("app_only", "both"):
        # Install entry: steps 4-6
        install_metadata = _build_app_metadata(
            config, recipe_path, version, package_path, "app_only", manifest, large_icon
        )
        intune_app_id = _upload_single_app(
            access_token,
            install_metadata,
            package_path,
            intunewin_metadata,
            existing_apps,
            recipe_id=app_id,
            entry=ENTRY_INSTALL,
            installer_sha256=installer_sha256,
            step_create=4,
            step_upload=5,
            step_commit=6,
            total_steps=total_steps,
            force=force,
        )

    if build_types in ("update_only", "both"):
        # Update entry: steps 4-6 (single) or 7-9 (both)
        step_offset = 6 if build_types == "both" else 3
        update_metadata = _build_app_metadata(
            config,
            recipe_path,
            version,
            package_path,
            "update_only",
            manifest,
            large_icon,
        )
        intune_update_app_id = _upload_single_app(
            access_token,
            update_metadata,
            package_path,
            intunewin_metadata,
            existing_apps,
            recipe_id=app_id,
            entry=ENTRY_UPDATE,
            installer_sha256=installer_sha256,
            step_create=step_offset + 1,
            step_upload=step_offset + 2,
            step_commit=step_offset + 3,
            total_steps=total_steps,
            force=force,
        )

    # Record the publication in deployment state: published version,
    # hash, and Intune app IDs; a matching pending slot is cleared.
    record_published(
        state,
        version=version,
        sha256=installer_sha256,
        intune_app_id=intune_app_id,
        intune_update_app_id=intune_update_app_id,
    )
    state["name"] = config["name"]
    save_deployment_state(state, state_path)
    logger.info("STATE", f"Recorded published release {version} in {state_path}")

    logger.verbose("UPLOAD", "Upload complete")

    return UploadResult(
        app_id=app_id,
        app_name=app_name,
        version=version,
        intune_app_id=intune_app_id,
        intune_update_app_id=intune_update_app_id,
        package_path=package_path,
        status="success",
    )

napt.upload.intunewin

Parses .intunewin package files for NAPT upload operations.

A .intunewin file is a ZIP archive created by IntuneWinAppUtil with the following structure:

IntuneWinPackage/
  Contents/
    IntunePackage.intunewin   <- encrypted payload
  Metadata/
    Detection.xml             <- encryption metadata

This module extracts the encryption metadata from Detection.xml and provides utilities for extracting the encrypted payload for upload to Azure Blob Storage.

IntunewinMetadata dataclass

Encryption metadata extracted from a .intunewin package.

All fields are sourced from Detection.xml inside the .intunewin ZIP archive. This metadata is required by the Graph API file commit endpoint.

Attributes:

Name Type Description
encrypted_file_name str

Filename of the encrypted payload inside the Contents/ directory (always "IntunePackage.intunewin").

unencrypted_content_size int

Original size in bytes before encryption.

file_digest str

Base64-encoded SHA-256 hash of the encrypted payload.

file_digest_algorithm str

Hash algorithm used (always "SHA256").

encryption_key str

Base64-encoded AES-256 encryption key.

mac_key str

Base64-encoded HMAC key for MAC verification.

init_vector str

Base64-encoded AES initialization vector.

mac str

Base64-encoded MAC value for integrity verification.

profile_identifier str

Encryption profile version (always "ProfileVersion1").

encrypted_file_size int

Byte size of the encrypted payload file.

Source code in napt/upload/intunewin.py
@dataclass(frozen=True)
class IntunewinMetadata:
    """Encryption metadata extracted from a .intunewin package.

    All fields are sourced from Detection.xml inside the .intunewin ZIP archive.
    This metadata is required by the Graph API file commit endpoint.

    Attributes:
        encrypted_file_name: Filename of the encrypted payload inside the
            Contents/ directory (always "IntunePackage.intunewin").
        unencrypted_content_size: Original size in bytes before encryption.
        file_digest: Base64-encoded SHA-256 hash of the encrypted payload.
        file_digest_algorithm: Hash algorithm used (always "SHA256").
        encryption_key: Base64-encoded AES-256 encryption key.
        mac_key: Base64-encoded HMAC key for MAC verification.
        init_vector: Base64-encoded AES initialization vector.
        mac: Base64-encoded MAC value for integrity verification.
        profile_identifier: Encryption profile version (always "ProfileVersion1").
        encrypted_file_size: Byte size of the encrypted payload file.
    """

    encrypted_file_name: str
    unencrypted_content_size: int
    file_digest: str
    file_digest_algorithm: str
    encryption_key: str
    mac_key: str
    init_vector: str
    mac: str
    profile_identifier: str
    encrypted_file_size: int

parse_intunewin

parse_intunewin(intunewin_path: Path) -> IntunewinMetadata

Parse a .intunewin package and extract encryption metadata.

Reads IntuneWinPackage/Metadata/Detection.xml from inside the .intunewin ZIP and returns all encryption fields required for the Graph API upload flow.

Parameters:

Name Type Description Default
intunewin_path Path

Path to the .intunewin file to parse.

required

Returns:

Type Description
IntunewinMetadata

Parsed encryption metadata from Detection.xml.

Raises:

Type Description
PackagingError

If the file is not a valid ZIP, Detection.xml is missing, or required XML fields are absent or malformed.

Example

Parse an existing package:

from pathlib import Path
from napt.upload.intunewin import parse_intunewin

metadata = parse_intunewin(
    Path("packages/napt-chrome/Invoke-AppDeployToolkit.intunewin")
)
print(metadata.encryption_key)

Source code in napt/upload/intunewin.py
def parse_intunewin(intunewin_path: Path) -> IntunewinMetadata:
    """Parse a .intunewin package and extract encryption metadata.

    Reads IntuneWinPackage/Metadata/Detection.xml from inside the .intunewin
    ZIP and returns all encryption fields required for the Graph API upload flow.

    Args:
        intunewin_path: Path to the .intunewin file to parse.

    Returns:
        Parsed encryption metadata from Detection.xml.

    Raises:
        PackagingError: If the file is not a valid ZIP, Detection.xml is missing,
            or required XML fields are absent or malformed.

    Example:
        Parse an existing package:
            ```python
            from pathlib import Path
            from napt.upload.intunewin import parse_intunewin

            metadata = parse_intunewin(
                Path("packages/napt-chrome/Invoke-AppDeployToolkit.intunewin")
            )
            print(metadata.encryption_key)
            ```

    """
    try:
        zf = zipfile.ZipFile(intunewin_path, "r")
    except zipfile.BadZipFile as err:
        raise PackagingError(
            f"{intunewin_path} is not a valid .intunewin file (invalid ZIP archive)"
        ) from err
    except OSError as err:
        raise PackagingError(f"Failed to open {intunewin_path}: {err}") from err

    with zf:
        # Read Detection.xml
        try:
            xml_bytes = zf.read(DETECTION_XML_PATH)
        except KeyError as err:
            raise PackagingError(
                f"{intunewin_path} is missing {DETECTION_XML_PATH}. "
                "The file may be corrupt or was not created by IntuneWinAppUtil."
            ) from err

        # Get encrypted payload file size
        try:
            payload_info = zf.getinfo(ENCRYPTED_PAYLOAD_PATH)
            encrypted_file_size = payload_info.file_size
        except KeyError as err:
            raise PackagingError(
                f"{intunewin_path} is missing {ENCRYPTED_PAYLOAD_PATH}. "
                "The file may be corrupt or was not created by IntuneWinAppUtil."
            ) from err

    # Parse XML
    try:
        root = ET.fromstring(xml_bytes)
    except ET.ParseError as err:
        raise PackagingError(f"Detection.xml contains invalid XML: {err}") from err

    # Extract namespace from root tag (e.g., '{http://schemas.microsoft.com/...}')
    ns = ""
    if root.tag.startswith("{"):
        ns = root.tag[: root.tag.index("}") + 1]

    # Read top-level fields
    encrypted_file_name = _require_text(root, "FileName", ns, "FileName")
    unencrypted_size_str = _require_text(
        root, "UnencryptedContentSize", ns, "UnencryptedContentSize"
    )
    try:
        unencrypted_content_size = int(unencrypted_size_str)
    except ValueError as err:
        raise PackagingError(
            f"Detection.xml UnencryptedContentSize is not an integer: "
            f"'{unencrypted_size_str}'"
        ) from err

    # Read EncryptionInfo subsection
    enc_info = root.find(f"{ns}EncryptionInfo")
    if enc_info is None:
        raise PackagingError(
            "Detection.xml is missing required section 'EncryptionInfo'. "
            "The .intunewin file may be corrupt."
        )

    encryption_key = _require_text(
        enc_info, "EncryptionKey", ns, "EncryptionInfo/EncryptionKey"
    )
    mac_key = _require_text(enc_info, "MacKey", ns, "EncryptionInfo/MacKey")
    init_vector = _require_text(
        enc_info, "InitializationVector", ns, "EncryptionInfo/InitializationVector"
    )
    mac = _require_text(enc_info, "Mac", ns, "EncryptionInfo/Mac")
    profile_identifier = _require_text(
        enc_info, "ProfileIdentifier", ns, "EncryptionInfo/ProfileIdentifier"
    )
    file_digest = _require_text(enc_info, "FileDigest", ns, "EncryptionInfo/FileDigest")
    file_digest_algorithm = _require_text(
        enc_info, "FileDigestAlgorithm", ns, "EncryptionInfo/FileDigestAlgorithm"
    )

    return IntunewinMetadata(
        encrypted_file_name=encrypted_file_name,
        unencrypted_content_size=unencrypted_content_size,
        file_digest=file_digest,
        file_digest_algorithm=file_digest_algorithm,
        encryption_key=encryption_key,
        mac_key=mac_key,
        init_vector=init_vector,
        mac=mac,
        profile_identifier=profile_identifier,
        encrypted_file_size=encrypted_file_size,
    )

extract_encrypted_payload

extract_encrypted_payload(intunewin_path: Path, dest_dir: Path) -> Path

Extract the encrypted payload from a .intunewin package.

Extracts IntuneWinPackage/Contents/IntunePackage.intunewin to the destination directory for upload to Azure Blob Storage.

Parameters:

Name Type Description Default
intunewin_path Path

Path to the .intunewin file.

required
dest_dir Path

Directory to extract the payload into.

required

Returns:

Type Description
Path

Path to the extracted encrypted payload file.

Raises:

Type Description
PackagingError

If the file is not a valid ZIP or the payload is missing.

Source code in napt/upload/intunewin.py
def extract_encrypted_payload(intunewin_path: Path, dest_dir: Path) -> Path:
    """Extract the encrypted payload from a .intunewin package.

    Extracts IntuneWinPackage/Contents/IntunePackage.intunewin to the
    destination directory for upload to Azure Blob Storage.

    Args:
        intunewin_path: Path to the .intunewin file.
        dest_dir: Directory to extract the payload into.

    Returns:
        Path to the extracted encrypted payload file.

    Raises:
        PackagingError: If the file is not a valid ZIP or the payload is missing.

    """
    try:
        zf = zipfile.ZipFile(intunewin_path, "r")
    except zipfile.BadZipFile as err:
        raise PackagingError(
            f"{intunewin_path} is not a valid .intunewin file (invalid ZIP archive)"
        ) from err
    except OSError as err:
        raise PackagingError(f"Failed to open {intunewin_path}: {err}") from err

    with zf:
        try:
            zf.extract(ENCRYPTED_PAYLOAD_PATH, dest_dir)
        except KeyError as err:
            raise PackagingError(
                f"{intunewin_path} is missing {ENCRYPTED_PAYLOAD_PATH}. "
                "The file may be corrupt or was not created by IntuneWinAppUtil."
            ) from err

    # zipfile.extract preserves the full path structure inside dest_dir
    return dest_dir / ENCRYPTED_PAYLOAD_PATH