Application Packaging

Creating a PSADT 4 Package — A 7-Zip Walkthrough

A complete walkthrough of building a PSADT 4 package from scratch, using 7-Zip as the example — from source acquisition and MSI inspection through wrapper authoring, local testing, code signing, and handoff to ConfigMgr.

PSADT Packaging 7-Zip PowerShell MSI Walkthrough Enterprise
2026-04-20 · Miloch · 22 min read

A PSADT package is the build output of application packaging. What the preceding article on PSADT 4 Application Deployment in ConfigMgr covered was the consumer side: how Configuration Manager takes a finished package and turns it into a deployed application. This article covers the producer side: how the package itself comes into being.

The walkthrough uses 7-Zip as its example. The choice is deliberate. 7-Zip is available as both MSI and EXE, ships with a stable product code across minor versions, has no licensing friction, is useful to almost every estate, and exposes the full range of decisions a packager must make — silent parameters, detection design, user-context handling, cleanup logic — without being trivial. A package that handles 7-Zip well is a package that can handle almost anything.

The workflow below tracks a real packaging session end to end: acquiring and verifying the source, inspecting the installer, setting up the PSADT template, writing each wrapper section, testing under both SYSTEM and user context, signing the entry point, and finally handing the package off to ConfigMgr. Every step is shown against the 7-Zip example, with the general pattern made explicit so that the same approach applies to any other application.

The Packaging Workspace

Before any installer is touched, a packaging workspace is established. A well-organised workspace pays dividends throughout the lifecycle of every package; a chaotic workspace guarantees that a package revision six months from now will be impossible to reproduce.

Folder Convention

The convention used here follows vendor → product → version:

text
\\packaging\Packages\
├── 7-Zip\
│   ├── 24.09\
│   │   ├── Source\                        ← original vendor installer + checksums
│   │   ├── Package\                       ← PSADT package, ready for deployment
│   │   └── Notes.md                       ← packaging journal for this version
│   └── 24.08\                             ← previous version, retained for rollback
├── Google-Chrome\
│   └── 128.0.6613.138\
└── _Templates\
    └── PSAppDeployToolkit_Template_v4\    ← untouched toolkit template

Source holds the pristine vendor download plus the published checksum. Package holds the PSADT structure with the installer embedded. Notes.md is a plain journal — packaging decisions, quirks discovered during testing, dates of sign-off. In practice, Notes.md is what makes a package maintainable by a successor; it is worth the five minutes it takes to write.

The PSADT Template

The toolkit itself is a one-time download from the PSAppDeployToolkit repository. The release package contains a Template folder that serves as the starting point for every new package. This template is never edited in place — it is copied for each package, and the copy is what the packager works on.

powershell
# Copy the template for a new package
Copy-Item `
    -Path   '\\packaging\Packages\_Templates\PSAppDeployToolkit_Template_v4' `
    -Destination '\\packaging\Packages\7-Zip\24.09\Package' `
    -Recurse

Updates to the toolkit itself (minor releases, security patches) are applied by re-downloading the template and carrying forward only the customised sections of the wrapper — never by attempting a partial overlay.

Acquiring the Installer

Source and Integrity

7-Zip is downloaded from 7-zip.org. Two variants matter:

For a PSADT package, the MSI is the superior choice. It exposes a stable product code, responds predictably to msiexec arguments, writes a proper uninstall entry to the registry, and produces a structured log when invoked with /l*v. The EXE variant is retained as a fallback for platforms where the MSI is unavailable.

The vendor publishes a SHA-256 checksum next to each download. Verifying the checksum before packaging is mandatory — a tampered or truncated installer discovered during production deployment is the kind of incident that ends careers.

powershell
# Verify the SHA-256 of the downloaded file
$expected = 'REPLACE-WITH-PUBLISHED-HASH'
$actual   = (Get-FileHash '.\Source\7z2409-x64.msi' -Algorithm SHA256).Hash
if ($actual -ne $expected) { throw "Hash mismatch — aborting packaging" }

The verified file is then staged in the package under Files\:

powershell
Copy-Item `
    -Path   '.\Source\7z2409-x64.msi' `
    -Destination '.\Package\Files\7z2409-x64.msi'

Inspecting the MSI

Before any wrapper code is written, the installer's internal structure is examined. Two tools cover the common cases: the Windows Installer command line, and Microsoft's MSI table editor Orca.

For routine inspection, the command line is sufficient:

powershell
# Run an administrative install into a scratch folder to extract the MSI tables
msiexec /a '.\Files\7z2409-x64.msi' TARGETDIR='C:\Scratch\7zip' /qn

# Read the ProductCode, ProductVersion, and UpgradeCode from the extracted MSI
$msi = 'C:\Scratch\7zip\7z2409-x64.msi'
$wi  = New-Object -ComObject WindowsInstaller.Installer
$db  = $wi.OpenDatabase($msi, 0)

foreach ($prop in 'ProductCode','ProductVersion','UpgradeCode','Manufacturer') {
    $view = $db.OpenView("SELECT Value FROM Property WHERE Property = '$prop'")
    $view.Execute()
    $rec  = $view.Fetch()
    "{0,-16} = {1}" -f $prop, $rec.StringData(1)
}

Typical output for 7-Zip 24.09 x64:

text
ProductCode      = {23170F69-40C1-2702-2409-000001000000}
ProductVersion   = 24.09
UpgradeCode      = {23170F69-40C1-2702-0000-000004000000}
Manufacturer     = Igor Pavlov

The ProductCode is the MSI's unique identifier and the cleanest target for a detection method. The UpgradeCode is shared across versions and is what the MSI uses to recognise older installs for upgrade — useful when writing upgrade logic in the wrapper.

First Silent Install Test

Before any PSADT code is written, the raw silent install is proven on a clean test machine. If the vendor installer does not install silently by itself, no amount of wrapping will fix it.

powershell
# On a clean test VM
msiexec /i '.\7z2409-x64.msi' /qn /norestart /l*v 'C:\Temp\7zip-install.log'

The log at C:\Temp\7zip-install.log is inspected for:

For 7-Zip, the MSI installs cleanly with no additional properties. Other vendors are less cooperative; a packager who has not run the raw silent install first is guaranteed to lose hours later.

The PSADT Package Layout

With the template copied and the installer staged, the package folder now contains the following structure:

text
Package\
├── Files\
│   └── 7z2409-x64.msi                  ← primary vendor installer
├── SupportFiles\                        ← auxiliary content (empty for 7-Zip)
├── PSAppDeployToolkit\                  ← the toolkit module (do not edit)
│   ├── PSAppDeployToolkit.psm1
│   ├── PSAppDeployToolkit.psd1
│   ├── Config\
│   │   └── config.psd1
│   ├── Strings\
│   │   └── strings.psd1
│   └── ...
├── Invoke-AppDeployToolkit.ps1          ← the wrapper script (edited by packager)
└── Invoke-AppDeployToolkit.exe          ← signed entry point (called by deployment engine)

Two of these files are the packager's canvas:

Everything inside PSAppDeployToolkit\ is the toolkit module and is not modified by packagers. Custom behaviour is added through the extension mechanism (outside the scope of this article) or through the wrapper script.

Note
Editing files inside PSAppDeployToolkit\ produces a package that cannot be upgraded cleanly when a new toolkit release appears. Customisation belongs in the wrapper script or in a formal extension.

Writing the Wrapper — The Header

The top of Invoke-AppDeployToolkit.ps1 holds the application metadata. These fields feed the dialog titles, the log file names, and the strings shown in user-facing prompts. Filling them in is not cosmetic — a log file called PSAppDeployToolkit_Deploy-Install.log for every package is what makes a support queue impossible to triage.

powershell
## Application
[String]$appVendor       = '7-Zip'
[String]$appName         = '7-Zip'
[String]$appVersion      = '24.09'
[String]$appArch         = 'x64'
[String]$appLang         = 'EN'
[String]$appRevision     = '01'
[String]$appScriptVersion = '1.0.0'
[String]$appScriptDate    = '2026-04-20'
[String]$appScriptAuthor  = 'Miloch'

## Install Titles
[String]$installName  = "$appVendor $appName $appVersion"
[String]$installTitle = "$appVendor $appName $appVersion"

The revision field is critical for iterative packaging. When a package is rebuilt because a post-install tweak failed in the field, the revision increments (01 → 02) even if the application version did not change. This distinguishes the two builds in reporting and in the log file names.

The install title is what the end user sees in PSADT dialogs. Keeping it consistent with the ConfigMgr Application's display name is a small detail that makes the user experience coherent.

Pre-Installation Section

The Pre-Installation section handles everything that must happen before the vendor installer runs: telling the user what is about to happen, asking them to close running instances of the application, uninstalling incompatible previous versions, and cleaning up state that would otherwise poison the fresh install.

powershell
##*===============================================
##* PRE-INSTALLATION
##*===============================================
[String]$installPhase = 'Pre-Installation'

## Show welcome message, ask to close running 7-Zip instances, allow deferral
Show-ADTInstallationWelcome `
    -CloseProcesses @(
        @{ Name = '7zFM';     Description = '7-Zip File Manager' }
        @{ Name = '7zG';      Description = '7-Zip GUI' }
        @{ Name = '7z';       Description = '7-Zip Console' }
    ) `
    -CloseProcessesCountdown 600 `
    -DeferTimes 3 `
    -PersistPrompt `
    -CheckDiskSpace

## Uninstall previous 7-Zip (both MSI- and NSIS-based installs)
$previousProductCodes = Get-ADTInstalledApplication -Name '7-Zip' |
    Select-Object -ExpandProperty ProductCode

foreach ($code in $previousProductCodes) {
    if ($code) {
        Start-ADTMsiProcess -Action 'Uninstall' -FilePath $code
    }
}

## Remove NSIS-based 7-Zip if present (no product code)
$nsisUninstaller = 'C:\Program Files\7-Zip\Uninstall.exe'
if (Test-Path $nsisUninstaller) {
    Start-ADTProcess -FilePath $nsisUninstaller -ArgumentList '/S' -WaitForMsiExec
}

Several decisions in this block deserve explicit notice.

The close-processes list is explicit. Wildcard or partial process names lead to the wrong processes being killed when the user has multiple unrelated applications running. Listing each executable name individually with a human-readable description produces the clearest user dialog and the safest closure logic.

Countdown and deferral give the user agency. A ten-minute countdown (600 seconds) is long enough for the user to finish what they are doing; three deferrals across three policy cycles gives ample time to pick a better moment. PSADT handles all of this natively — no custom state-tracking is required.

Disk-space check is cheap. -CheckDiskSpace adds a pre-install validation against the MSI's estimated footprint. For small packages this is overhead; for 500 MB suites installed on fleet laptops with tight storage, it is what prevents a partial install that cannot roll back.

Previous-version handling uses the UpgradeCode-aware detection. Get-ADTInstalledApplication -Name '7-Zip' returns everything the Windows uninstall database knows about 7-Zip, MSI or otherwise. Iterating through the product codes produces a clean MSI uninstall for each. The separate NSIS cleanup handles the case where someone previously installed the EXE variant.

Installation Section

The Installation section is usually the shortest. When the pre-installation work is done correctly, the actual vendor install is one or two lines.

powershell
##*===============================================
##* INSTALLATION
##*===============================================
[String]$installPhase = 'Installation'

## Install 7-Zip MSI
Start-ADTMsiProcess `
    -Action 'Install' `
    -FilePath "$($adtSession.DirFiles)\7z2409-x64.msi" `
    -AddParameters 'ALLUSERS=1 REBOOT=ReallySuppress'

Two points about this call:

$adtSession.DirFiles resolves to the package's Files\ folder at runtime. Hardcoding paths like C:\Temp\Files\ is what breaks a package the moment it runs from the ConfigMgr cache instead of the packaging share.

ALLUSERS=1 forces a per-machine install. Some MSIs default to per-user unless told otherwise; 7-Zip is one of them when elevation is unclear. REBOOT=ReallySuppress prevents the MSI from scheduling a reboot on its own; reboot orchestration belongs to the deployment engine, not to the installer.

PSADT's Start-ADTMsiProcess handles the log path automatically — the MSI log is written to the toolkit's log directory with a deterministic filename based on $installName and the phase.

Exit-Code Handling

Not every non-zero exit code is a failure. 3010 means success, but a reboot is required; 1641 means success, reboot initiated. PSADT interprets these correctly by default, marking the deployment as successful while signalling to the deployment engine that a reboot is pending. No custom handling is required for the common cases.

When a vendor installer uses non-standard exit codes (some do), the wrapper can translate them explicitly:

powershell
$result = Start-ADTProcess `
    -FilePath "$($adtSession.DirFiles)\vendor-installer.exe" `
    -ArgumentList '/S' `
    -PassThru

# Vendor returns 10 for "success but config pending"; translate to 0
if ($result.ExitCode -eq 10) {
    [Int32]$mainExitCode = 0
}

For 7-Zip, the standard MSI exit codes are sufficient.

Post-Installation Section

The Post-Installation section handles anything the vendor installer cannot be trusted to do, or does badly. For 7-Zip, this typically means setting file associations, tidying start-menu shortcuts, and optionally seeding per-machine preferences.

powershell
##*===============================================
##* POST-INSTALLATION
##*===============================================
[String]$installPhase = 'Post-Installation'

## Set 7-Zip as the default handler for archive types (per-machine)
$archiveTypes = @('.7z', '.zip', '.tar', '.gz', '.bz2', '.xz', '.rar')
foreach ($ext in $archiveTypes) {
    Set-ADTRegistryKey `
        -Key     "HKLM:\SOFTWARE\Classes\$ext\OpenWithProgids" `
        -Name    '7-Zip.File' `
        -Value   '' `
        -Type    String
}

## Remove the 'Visit 7-Zip website' start menu entry (optional policy)
$unwantedShortcut = "$envCommonStartMenuPrograms\7-Zip\7-Zip Web Site.lnk"
if (Test-Path $unwantedShortcut) {
    Remove-ADTFile -Path $unwantedShortcut
}

## Show completion message (only when running interactively)
Show-ADTInstallationPrompt `
    -Message "7-Zip $appVersion has been installed successfully." `
    -ButtonRightText 'OK' `
    -Icon Information `
    -NoWait

Whether any of this is appropriate is a policy decision. Some estates mandate 7-Zip as the default archive handler; others leave file associations to the user. The wrapper documents the decision in code, and the same code will apply consistently across the fleet.

-NoWait on the completion prompt prevents the deployment from hanging on an unattended machine. The prompt appears, auto-dismisses, and the wrapper proceeds to its natural end.

Uninstall Logic

A good package uninstalls as cleanly as it installs. Uninstall logic follows the same three-section structure.

Pre-Uninstallation

powershell
##*===============================================
##* PRE-UNINSTALLATION
##*===============================================
[String]$installPhase = 'Pre-Uninstallation'

Show-ADTInstallationWelcome `
    -CloseProcesses @(
        @{ Name = '7zFM';     Description = '7-Zip File Manager' }
        @{ Name = '7zG';      Description = '7-Zip GUI' }
        @{ Name = '7z';       Description = '7-Zip Console' }
    ) `
    -CloseProcessesCountdown 300 `
    -PersistPrompt

Uninstall does not offer deferral — when a user has been told their application will be removed, the removal must proceed. A three-hundred-second countdown is typically sufficient to save open files.

Uninstallation

powershell
##*===============================================
##* UNINSTALLATION
##*===============================================
[String]$installPhase = 'Uninstallation'

Start-ADTMsiProcess `
    -Action 'Uninstall' `
    -FilePath '{23170F69-40C1-2702-2409-000001000000}'

The ProductCode is hardcoded. This ties the wrapper to this specific MSI build — which is correct, because a wrapper for version 24.09 should uninstall 24.09, not any 7-Zip that happens to be on the system. Version-agnostic cleanup, when needed, belongs in a separate cleanup package.

Post-Uninstallation

powershell
##*===============================================
##* POST-UNINSTALLATION
##*===============================================
[String]$installPhase = 'Post-Uninstallation'

## Remove the 7-Zip registry hive that 7-Zip's MSI leaves behind
Remove-ADTRegistryKey -Key 'HKLM:\SOFTWARE\7-Zip' -Recurse

## Remove the install folder if non-empty leftovers exist
Remove-ADTFolder -Path "$envProgramFiles\7-Zip"

MSI uninstall is supposed to remove everything the MSI created, but many vendors leave debris — empty folders, registry keys that were written by the installer outside the MSI's control, user-created files in the install folder. The post-uninstallation section cleans these up so the filesystem is genuinely as it was before the install.

Local Testing

A package that has not been tested is a theoretical package. PSADT makes testing straightforward as long as both contexts are exercised.

Interactive Test (User Context)

From an elevated PowerShell prompt logged on as an administrator:

powershell
Set-Location '\\packaging\Packages\7-Zip\24.09\Package'
.\Invoke-AppDeployToolkit.exe -DeploymentType Install -DeployMode Interactive

The expected flow:

  1. A welcome dialog appears, listing any open 7-Zip processes
  2. The install progresses with a PSADT progress bar
  3. The completion prompt appears briefly
  4. The wrapper exits with code 0

Common issues discovered at this stage:

Silent Test (System Context)

System-context execution simulates what ConfigMgr does. The cleanest way to reproduce it is PsExec from Sysinternals:

powershell
# Run the wrapper as LOCAL SYSTEM, interactively, in the current session
psexec.exe -i -s powershell.exe -File '\\packaging\Packages\7-Zip\24.09\Package\Invoke-AppDeployToolkit.ps1' `
    -DeploymentType Install -DeployMode Silent

A successful silent run produces:

If the silent run fails where the interactive run succeeded, the difference is almost always in a step that assumed an interactive token — a UAC prompt, an HKCU-only registry write, a dialog that waited for user input.

Log Locations After a Test

All PSADT logs end up under C:\Windows\Logs\Software\ by default, with filenames that combine the install title and the phase. For the 7-Zip package above, a successful install produces something like:

text
C:\Windows\Logs\Software\
├── 7-Zip_7-Zip_24.09_EN_01.log                   ← master PSADT log
└── 7-Zip_7-Zip_24.09_EN_01.msi.log               ← MSI log captured by Start-ADTMsiProcess

Reading these two logs after every test is the baseline verification. An anomaly that is not visible in either log is rare.

Signing the Entry Point

In enterprise environments, the Invoke-AppDeployToolkit.exe entry point is signed with a code-signing certificate before the package is distributed. Signing produces two benefits: it allows SmartScreen and AppLocker policies to treat the executable as trusted, and it establishes a verifiable chain from the packager's identity to the deployed artifact.

Signing requires a code-signing certificate from an internal CA (the lab's Enterprise CA, or a commercial provider for internet-facing distribution). The certificate must be installed in the signing user's personal certificate store with the private key.

powershell
# Locate the code-signing certificate
$cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert |
    Where-Object { $_.Subject -like '*CN=Packaging Signing*' } |
    Select-Object -First 1

# Sign the entry-point executable
Set-AuthenticodeSignature `
    -FilePath '.\Invoke-AppDeployToolkit.exe' `
    -Certificate $cert `
    -TimestampServer 'http://timestamp.digicert.com'

The timestamp server is important: without it, the signature becomes invalid when the certificate eventually expires. With a timestamp, the signature remains valid as long as it was timestamped during the certificate's validity window, even long after the certificate has expired.

The wrapper script (Invoke-AppDeployToolkit.ps1) can be signed as well, but many enterprises rely on the PowerShell execution-policy bypass that the entry-point executable applies internally, and sign only the executable. Either approach is defensible.

Warning
Signing must happen after all edits to the file are complete. Editing a signed file invalidates the signature. Establishing a build pipeline that signs as the final step, after all automated tests pass, is the clean pattern.

Handing Off to ConfigMgr

The finished package folder — with a signed entry point, a tested wrapper, and a staged installer — is the content source for a ConfigMgr Application. The steps from here are covered in detail in the deployment article: creating the Application, defining a Script Installer deployment type, wiring up the install and uninstall commands against Invoke-AppDeployToolkit.exe, configuring the detection method against the MSI product code, and distributing the content to a Distribution Point.

For 7-Zip specifically, the ConfigMgr configuration that pairs with this package:

The Application is then deployed to a pilot collection, verified against Software Center on the test client, and — after the pilot clears — promoted to the broader collection.

Common Pitfalls

Silent arguments that are not actually silent A vendor installer documented as silent with /S sometimes pops a dialog anyway when it detects a previous version. Running the raw silent install first, on a clean VM, catches this before the wrapper is written. The wrapper then either uninstalls the previous version first (as shown above) or passes an additional flag to suppress the dialog.

MSI installs silently as a per-user install When an MSI is invoked without ALLUSERS=1 and without elevation, some installers default to per-user. The installer succeeds, but the resulting deployment is visible only to the user who triggered it. ALLUSERS=1 in -AddParameters prevents this category of silent failure.

Quotes inside Start-ADTProcess arguments Arguments containing paths with spaces must be quoted correctly. PowerShell's string handling plus the deployment engine's command parser produce four levels of quoting in the worst case. The cleanest pattern is the array form:

powershell
Start-ADTProcess `
    -FilePath 'C:\Program Files\Vendor\installer.exe' `
    -ArgumentList @(
        '/S'
        '/CONFIG="C:\Temp\config.ini"'
        '/LOG="C:\Temp\install.log"'
    )

Using an array avoids the string-concatenation pitfalls entirely.

Close-process list does not close anything The process name in Show-ADTInstallationWelcome -CloseProcesses must match the executable name without .exe. A typo or a changed name between versions produces a welcome dialog that lists no processes even when the application is open. A quick Task Manager check confirms the actual process name.

Wrapper runs but nothing installs A 1619 exit code from msiexec means the package could not be opened. Common causes: the MSI was placed in SupportFiles\ instead of Files\, the path was hardcoded relative to the packaging share, or the file was lost during content distribution. Always reference $adtSession.DirFiles for files in Files\ and $adtSession.DirSupportFiles for SupportFiles\.

Signing after the fact invalidates ConfigMgr content hashes If a package is distributed to DPs and then signed retroactively, the content hashes on the DPs no longer match the package on the share. ConfigMgr will redistribute, but in the interim, clients that pull the old cached content will fail signature validation at the client side. Sign before distributing.

Revision not incremented after a rebuild A package rebuilt to fix a post-install bug with the same $appVersion and $appRevision produces logs that overwrite each other. Two different builds with the same log filename is what makes a production incident impossible to analyse in retrospect. Incrementing $appRevision on every rebuild is cheap insurance.

HKCU writes from a SYSTEM-context wrapper SYSTEM has its own HKCU, which is not the logged-on user's HKCU. A wrapper that writes to HKCU under SYSTEM writes to a hive that no user will ever read. Per-user configuration belongs either in a per-user deployment (user collection) or in the Default User profile so that new logons inherit it, written through reg load / reg unload under SYSTEM.

Forgetting to test the uninstall Uninstall is as much a part of the package as install. A package that cannot cleanly uninstall accumulates in the estate and blocks upgrades. The local testing phase always includes Install → Uninstall → Install, with log inspection at each step.

Takeaways

Packaging is iterative by nature. A first pass produces a wrapper that installs; the second pass cleans up the install; the third pass tightens the uninstall; subsequent passes handle edge cases discovered during pilot rollouts. Attempting to get everything right in one session produces a wrapper that is both overengineered and brittle. Each section — Pre-Install, Install, Post-Install, and the three uninstall counterparts — is refined against real failures observed on real test machines.

The 7-Zip walkthrough above is the full pattern in a minimal case. A commercial suite with prerequisites, licence activation, user-profile seeding, and post-install registration extends the pattern without changing it. The same sections hold the same kinds of logic; only the details scale.

Two disciplines separate reliable packages from fragile ones. The first is testing under both contexts — interactive and SYSTEM — every time the wrapper is modified, not only at the end. The second is reading the log after every test, not only when something visibly fails. Silent failures with a zero exit code are real, and the log is where they surface.

Rule of thumb: Install it by hand first, silently, with a full MSI log. If that step does not succeed cleanly, no wrapper can paper over the result. Only when the raw installer behaves under msiexec /qn is it time to write a PSADT section around it.


Tested with: PSAppDeployToolkit v4 on Windows 11 24H2, 7-Zip 24.09 x64 MSI, Windows Server 2025 as the packaging share host, Configuration Manager Current Branch 2503 as the downstream deployment engine.

PSADT Packaging 7-Zip PowerShell MSI Walkthrough Enterprise
M

Miloch

Enterprise IT, SCCM & ConfigMgr, PowerShell & Automation — building systems right.