An add-in that compiles is roughly halfway to being a product. The other half is delivery: getting a signed binary onto a managed workstation, in a way Revit will load, that an enterprise IT department will approve, and that survives being upgraded while the user has a model open.
This is a write-up of that half for NexusAI, a commercial Revit add-in covering Revit 2021 through 2027 from one codebase. It is the least glamorous work in the product and it consumed more calendar time than any feature.
The snippets below are simplified from the real build scripts: names shortened, error paths trimmed, and identifiers such as certificate aliases and package GUIDs replaced with placeholders. They are illustrative rather than copy-pasteable.
1. The part nobody writes down
There is a large amount of published material about the Revit API and almost none about shipping
against it. The Autodesk documentation covers the manifest format. Blog posts cover
IExternalCommand. What is missing is everything between a green build and a user
double-clicking an installer:
- Seven Revit versions, three .NET runtimes, one source tree.
- An embedded CPython runtime of several hundred files that has to be signed and deduplicated.
- A machine-wide install location that requires elevation, and that Autodesk relocated in 2027.
- A catalog file whose absence causes Revit to ignore your add-in silently, with no error anywhere.
- A Windows security ecosystem that treats every newly signed binary as guilty until it has accumulated reputation, which takes months.
Each of these is individually solvable and collectively a genuine project. The reason it is worth writing down is that the failure modes are nearly all silent.
2. Signing when the key is not on your machine
Since June 2023 the CA/Browser Forum has required code-signing private keys to live on hardware certified to FIPS 140-2 Level 2 or equivalent. Practically, that leaves two options: a physical USB token, or a cloud HSM.
A USB token cannot be automated in any way you would want to depend on. It prompts for a PIN per signature, it has to be physically present, and it makes your release process a function of which laptop you are sitting at. For a build that signs a few hundred files, it is unusable.
NexusAI signs through DigiCert's KeyLocker service instead. The key stays in a cloud HSM and a
local CLI, smctl, submits files to it. Authentication is a client certificate plus
an API key supplied through environment variables, which live in a gitignored file the build
script dot-sources if present. Nothing machine-specific is in the repository, and a build
machine without those credentials simply cannot sign.
The signing wrapper is four lines of work and one important defensive check:
function Invoke-Sign([string]$target) {
$output = & $smctl sign --simple --keypair-alias $Alias --input $target 2>&1
$outputText = $output -join "`n"
# smctl exits 0 even when the signature was rejected. An expired credential
# or an API failure produces a zero exit and an error in stdout. Trusting
# $LASTEXITCODE alone ships an unsigned installer that looks like a clean build.
$failed = ($LASTEXITCODE -ne 0) -or
($outputText -match "was failed") -or
($outputText -match '"error"')
if ($failed) {
throw "Signing failed for $(Split-Path $target -Leaf)`n$outputText"
}
$script:SigningCount++
}
That comment is the single most useful thing in this section. A signing step that fails open is worse than no signing step, because the artifact still has the right filename and the failure does not surface until a user's machine refuses to run it. Anything that shells out to a signing tool should verify the resulting signature, not the tool's exit code.
3. Sign everything, and ship less
Authenticode is per file. It is tempting to sign only the installer, since that is what SmartScreen shows the user, but enterprise endpoint protection scans what lands on disk. An unsigned native DLL sitting in the same folder as a signed managed assembly is structurally indistinguishable from DLL side-loading, and it gets treated accordingly.
NexusAI ships an in-process CPython runtime, so "everything on disk" is a few hundred PE files. Most already carry valid signatures from the Python Software Foundation or Microsoft. A handful do not: the Python.NET interop assemblies, a native loader shim in both architectures, and one OpenBLAS binary. Those need signing; the rest must not be re-signed, because signatures are metered against an annual quota and re-signing several hundred files per build would exhaust it in a few releases.
function Invoke-SignIfUnsigned([string]$target) {
if ((Get-AuthenticodeSignature -FilePath $target).Status -eq 'Valid') { return }
Invoke-Sign $target
}
function Invoke-SignDirectory([string]$dir) {
Get-ChildItem $dir -Recurse -Include "*.dll", "*.exe" |
ForEach-Object { Invoke-SignIfUnsigned $_.FullName }
}
Because signatures persist on the files in the source tree, the first build after setting up the Python runtime pays for all of them and every subsequent build pays for the add-in assemblies and the installer. The build prints its signature count at the end, which is a small thing that has caught a misconfigured skip more than once.
Deleting is cheaper than signing
The other half of this is shipping less. A pip-installed Python environment contains a
surprising number of small unsigned executables: the console-script launchers in
Scripts\, and pip's own vendored launcher templates buried in
site-packages. Around two dozen unsigned EXEs in total, none of which are needed at
runtime because packages are installed at build time and never from the user's machine.
So the build removes them, along with pip, wheel and setuptools entirely, before the signing pass runs. That is two dozen fewer files to sign, two dozen fewer things for an endpoint agent to have an opinion about, and a smaller installer.
Every unsigned executable inside a signed installer is a coin flip you did not have to take. Before you sign it, check whether the product needs it at all.
4. Letting Autodesk load the add-in
There are two ways to get loaded by Revit. The old one is to drop a .addin manifest
into a per-year Addins\{year} folder. The other is to ship an
ApplicationPlugins bundle: a directory with a fixed layout and a
PackageContents.xml catalog at its root, which Autodesk's autoloader reads.
The bundle is more work and it is the right choice, for a reason that has nothing to do with elegance: it is the layout the Autodesk App Store MSI produces. Using it for the direct-download installer too means the store submission and the website installer put identical files in identical places, so there is one deployment to test rather than two.
NexusAI.bundle\
PackageContents.xml
Contents\
myHelp.html
2024\
NexusAI.addin
NexusAI\ (managed DLLs + python_dir.txt)
2025\
NexusAI.addin
NexusAI\
...
The catalog is where the version targeting happens, and it is the one file worth being pedantic
about. It is generated per build from the years that actually compiled, one
<Components> block each, with a single-version range:
foreach ($year in ($years | Sort-Object)) {
[void]$sb.AppendLine(" <Components Description=`"Revit $year`">")
[void]$sb.AppendLine(" <RuntimeRequirements OS=`"Win64`" Platform=`"Revit`" " +
"SeriesMin=`"R$year`" SeriesMax=`"R$year`" />")
[void]$sb.AppendLine(" <ComponentEntry AppName=`"NexusAI`" Version=`"$version`" " +
"ModuleName=`"./Contents/$year/NexusAI.addin`" />")
[void]$sb.AppendLine(" </Components>")
}
# Autodesk's parser rejects a leading byte-order mark, and PowerShell's default
# UTF8 output writes one. Silent: the bundle is simply never loaded.
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($pkgPath, $sb.ToString(), $utf8NoBom)
The result looks like this, and the repetition is deliberate:
<ApplicationPackage SchemaVersion="1.0" AutodeskProduct="Revit"
ProductType="Application" Name="NexusAI" AppVersion="1.3.3"
Author="Project Nexus LLC" HelpFile="./Contents/myHelp.html"
ProductCode="{...}" UpgradeCode="{...}">
<CompanyDetails Name="Project Nexus LLC" Url="https://getnexusai.net" />
<Components Description="Revit 2024">
<RuntimeRequirements OS="Win64" Platform="Revit" SeriesMin="R2024" SeriesMax="R2024" />
<ComponentEntry AppName="NexusAI" ModuleName="./Contents/2024/NexusAI.addin" />
</Components>
<Components Description="Revit 2025">
<RuntimeRequirements OS="Win64" Platform="Revit" SeriesMin="R2025" SeriesMax="R2025" />
<ComponentEntry AppName="NexusAI" ModuleName="./Contents/2025/NexusAI.addin" />
</Components>
</ApplicationPackage>
A single block spanning SeriesMin="R2021" to SeriesMax="R2027" is
shorter and wrong. Revit 2024 and Revit 2025 do not run on the same .NET, so the assembly that
works in one cannot load in the other. Per-year blocks are the only reliable way to say "load
exactly this build in exactly this version," and getting it wrong produces an assembly load
failure inside Revit rather than anything resembling a helpful message.
Two silent failures worth guarding
If PackageContents.xml is missing from the bundle root, Revit ignores the entire
bundle. No dialog, no journal entry a normal person would find, no ribbon tab. The add-in simply
does not exist. This happened once during the install-root split described next, and the
symptom was "2021 through 2026 stopped working" with nothing to go on.
So the build now hard-fails before packaging if either staged bundle root is missing its catalog. The related footgun lives in the installer script: a wildcard covering the bundle directory has been observed to omit the root catalog file, so it is listed as its own explicit entry.
[Files]
; Shared Python runtime, installed once and pointed at by every year (see below).
Source: "{#PythonSrcDir}\*"; DestDir: "{#SharedPythonDir}"; Flags: recursesubdirs createallsubdirs
; ProgramData bundle for Revit 2021-2026. PackageContents.xml is listed explicitly:
; a bare Contents wildcard has been observed to omit the root catalog, and without
; it Revit ignores the whole bundle without reporting anything.
Source: "{#StagingDir}\NexusAI.bundle\PackageContents.xml"; DestDir: "{#BundleDirProgramData}"; Flags: skipifsourcedoesntexist
Source: "{#StagingDir}\NexusAI.bundle\Contents\*"; DestDir: "{#BundleDirProgramData}\Contents"; Flags: recursesubdirs createallsubdirs skipifsourcedoesntexist; Excludes: "*.pdb"
skipifsourcedoesntexist matters more than it looks. A build machine only compiles
the Revit versions it actually has installed, so a machine without Revit 2027 stages no 2027
bundle. Without that flag the installer fails to compile instead of producing a correct
installer for the versions that were built.
5. Two install roots, because 2027 moved
Revit 2021 through 2026 discover all-users ApplicationPlugins under
%ProgramData%. Revit 2027 moved all-users discovery to %ProgramFiles%
as a security change, and no longer looks in the old location.
Installing the same bundle to both paths does not work, and the reason is the catalog again. Each
root's PackageContents.xml must list only the years that belong in that root.
Advertise R2021 from the Program Files copy and a 2027 install will happily try to load a
.NET Framework assembly.
So the build stages two bundle roots and splits the compiled years between them by number, then derives each catalog from what is actually on disk rather than from what it intended to build:
foreach ($year in $SuccessYears) {
if ([int]$year -ge 2027) { $YearsForProgramFiles.Add($year) }
else { $YearsForProgramData.Add($year) }
}
# ... move each 2027+ year folder into the Program Files staging root, and clear
# any leftovers from a previous build in the opposite root ...
# The catalog is written from the folders that exist, not from the build roster.
# Those two disagree whenever a build partially fails, and the catalog has to
# describe reality or Revit loads a ComponentEntry pointing at nothing.
$ProgramDataYears = @(Get-StagedYearFolders $BundleContents)
$ProgramFilesYears = @(Get-StagedYearFolders $BundleContentsPf)
Deriving the catalog from the filesystem rather than from the intended year list is a small decision that removes a whole class of bug. A build where one version fails to compile now produces a valid installer for the rest, instead of a catalog promising a component that was never staged.
6. One runtime, seven add-ins
The add-in hosts CPython in-process. Shipping that runtime once per Revit year would multiply the largest thing in the package by seven, for seven identical copies.
Instead it is installed once, to a shared location, and each year's assembly folder gets a one-line pointer file written after install. The add-in resolves its runtime from that file, and falls back to a path next to the assembly when the file is absent:
// Installed builds read the shared runtime location from a file the installer
// writes post-install. A Visual Studio build has no such file and finds the
// runtime next to the assembly. Same binary, no configuration, no build flag.
string pythonDirFile = Path.Combine(assemblyDir, "python_dir.txt");
string pythonHome = File.Exists(pythonDirFile)
? File.ReadAllText(pythonDirFile).Trim()
: Path.Combine(assemblyDir, "bin", "python");
The installer writes those files in a post-install step, walking each
Contents\{year}\ folder it finds under both bundle roots. Doing it in code rather
than as a static file entry means it adapts to whichever years the build produced.
One consequence is easy to miss: a file created after installation is not in the install log, so the uninstaller does not know about it and will leave the bundle directory behind. The uninstall section therefore removes the bundle roots as directories rather than relying on file tracking. A clean uninstall is not a nicety here. It is the first thing an enterprise reviewer checks.
7. Upgrading over a running Revit
Revit memory-maps the add-in assembly and the native Python DLLs for the entire session. An
in-place upgrade while Revit is open cannot replace them, and the user sees
DeleteFile failed; code 5. Access is denied. partway through an install, having
already had some files replaced.
Inno Setup's Restart Manager integration helps, but it runs as part of the install sequence and its default behavior is to close the application. Closing Revit under someone with an unsaved model is not an acceptable outcome. What is needed is to establish the precondition before any file is touched, and to make it the user's decision.
{ PrepareToInstall runs BEFORE Setup's file-in-use / Restart Manager check, which
is the entire point: once this returns, Revit is already gone and both the file
copy and the legacy-cleanup DelTree can succeed. }
function PrepareToInstall(var NeedsRestart: Boolean): String;
begin
Result := '';
while IsRevitRunning() do
begin
if MsgBox('Revit is currently open and is using files that NexusAI needs to update.'
+ #13#10#13#10 +
'Please save your work and close all Revit windows, then click "Retry".',
mbConfirmation, MB_RETRYCANCEL) = IDCANCEL then
begin
Result := 'Setup was cancelled because Revit is still running.';
Exit;
end;
{ Give Revit a moment to release its handles before re-checking. }
Sleep(1000);
end;
end;
The process check goes through WMI, and it fails open: if the query throws, the function reports that Revit is not running and the install proceeds. That is deliberate. A detection mechanism that blocks installation when it cannot answer is worse than one that occasionally lets a locked file through, because the fallback path is still there. Restart Manager still runs, and the stock file-in-use messages have been rewritten to say "save your work and close Revit" rather than quoting a Win32 error.
Cleaning up your own history
Earlier versions installed into the per-year Addins\{year} folders, using the same
AddInId the bundle now uses. If those files survive an upgrade, Revit discovers the
add-in twice with one identity and errors on the duplicate. The user experiences this as the
upgrade breaking the product.
The installer therefore deletes every historical install location before copying anything: the all-users per-year folders, the per-user roaming equivalents, and the per-year folder a Visual Studio build writes for 2027. It also wipes both bundle roots, because a period when the installer wrote every year to both locations can leave stale year folders that the current catalog does not mention.
An installer is not just a copy operation. It is a migration from every shape your product has ever had on disk.
8. Deleting the auto-updater
This is the section I would most want a delivery manager to read, because it is a case where a security constraint changed the architecture rather than the packaging.
NexusAI used to ship an updater executable. It checked a version endpoint, downloaded an archive, and replaced the add-in's DLLs in place on next start. Convenient, and completely standard.
To work, it needed the install folder to be writable by a non-administrator, since Revit runs as the user. That meant the installer granted write access to a machine-wide directory during install, and relaxed permissions on its own uninstall registry key.
Read that back as a behavioral profile: an elevated installer that loosens ACLs on a machine-wide folder, plus a bundled executable that downloads an archive from the internet and overwrites executables in place, plus a mechanism for running at startup. That is a reasonable description of an auto-updater and an equally reasonable description of a dropper. A heuristic engine cannot distinguish them, and the newer your certificate, the less benefit of the doubt you receive.
So the whole thing was removed. No updater binary, no ACL grants, no relaxed registry permissions; install folders keep the Windows default of admin-only write, and no shipped component ever modifies its own install directory. What replaced it splits detection from delivery:
The version comparison lives in the cloud, so the update policy is configuration rather than code. The version delivery is a human double-clicking a signed EXE. Slower, and strictly better: no background download, no file swapping, no ACL relaxation, nothing for a heuristic to recognize.
The protocol version is the part I would keep in any similar system. It lets the cloud feature-detect what a connected desktop build supports, instead of string-comparing a marketing version number, which means new server behavior can adapt to old clients rather than requiring them to update first.
Antivirus reputation is a per-certificate asset. It accrues slowly, it is shared across everything you sign, and it resets when the certificate rotates. Treat any behavior that spends it as expensive, and check whether the same outcome can be achieved on the server, where a deploy costs minutes and nothing has to be signed.
9. Reputation as a budget
Even with nothing suspicious in the package, a new certificate means no reputation, and no reputation means SmartScreen warnings on download and occasional quarantine by enterprise endpoint agents. This is not a bug to fix. It is a starting condition to manage, and the two things that shorten it are a written behavior description and hashes ready to submit.
The behavior document is a page of plain prose stating what the installer writes, what the add-in does at runtime, which network endpoints it uses in each direction, and an explicit list of the behaviors it does not exhibit: no service, no driver, no autostart, no process injection, no ACL modification, no self-replacement, no inbound listener reachable off the machine. Writing it took an afternoon. It gets attached to every vendor submission, and it is also the fastest possible answer when a reseller's IT department asks what this thing does.
The hashes are generated as a build artifact, so no release is ever missing them:
function Get-FileFacts([string]$path) {
$sig = Get-AuthenticodeSignature -FilePath $path
[PSCustomObject]@{
Name = Split-Path $path -Leaf
Sha256 = (Get-FileHash -Path $path -Algorithm SHA256).Hash
SigStatus = $sig.Status
Signer = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject }
else { "(unsigned)" }
}
}
# An unsigned artifact must never reach a vendor portal. It gets classified on
# behavior alone, which is the exact outcome the signature exists to prevent.
$unsigned = $targets | Where-Object { $_.SigStatus -ne 'Valid' }
Two things in this area cost me real time and are worth stating plainly.
File reputation and URL reputation are different systems. An installer can be clean, correctly signed, and accepted by every file-scanning engine, and still be blocked at download time because the domain serving it has no reputation. The symptom is nearly identical from the user's side. The distinguishing question is whether the file was quarantined or the download was refused: the first is a file submission, the second is a domain categorization dispute at a completely different address. I spent a while submitting files to fix a problem that was never about the file.
Vendor submission requirements vary more than you would expect. One major vendor's allow-listing form wants the installer and its extracted components, because it only classifies hashes it can see. That means assembling an archive of the installer plus every executable it deploys. It is scriptable, and it is the sort of thing you do not want to discover manually at release time, so it is a switch on the same script.
Signing makes the build reproducible whether you like it or not
One last effect worth noting. Because every rebuild consumes signatures and produces new hashes that invalidate the submissions you already made, "just rebuild it" stops being free. That pressure is a good influence: the release process ended up as a single script plus a generated checklist naming the version-policy configuration to update and the exact files to publish, because the alternative is finding out later that the artifact you shipped is not the one you submitted.
10. Takeaways
The short version, for anyone about to ship a signed desktop add-in:
- Deployment is part of the architecture. The cost of changing a signed binary is high enough that it should influence where you put behavior in the first place.
- Verify signatures, never exit codes. A signing step that fails open produces an artifact that looks correct and is not.
- Sign everything you ship, and ship less. Deleting an unnecessary unsigned executable is cheaper and safer than signing it.
- Generate catalogs from the filesystem, then hard-fail on what is missing. Autodesk's autoloader fails silently, so the build has to be the thing that complains.
- Establish preconditions before you touch a file. Detect the running application up front and let the user act, rather than discovering the lock mid-copy.
- An installer is a migration. It has to clean up every layout your product has ever used, not just install the current one.
- Reputation is a budget. Move behavior to the server, where it costs a deploy instead of a signature and a re-earned reputation.
- Write the behavior document before you need it. It answers vendor submissions and enterprise security reviews with the same page.
None of this is difficult individually. What makes it a project is that most of the failures are silent, and the feedback loop runs through other people's security software.