feat(installer)!: one self-selecting installer, upgrades that keep the config, and an asymmetric arch migration - #84
Open
EliorMachlev wants to merge 9 commits into
Open
Conversation
…e agent secret The wizard sequenced JenkinsConfigDlg -> SecurityOptionsDlg -> AdvancedOptionsDlg unconditionally, and RequiredFieldDlg blocked Next until JENKINS_URL and JENKINS_SECRET were both non-empty. On an upgrade that demanded the controller URL and a re-pasted agent secret and then threw both away: WriteConfig, WriteAdvanced1-3 and SetDataDir are all gated NOT WIX_UPGRADE_DETECTED and do not run, UpgradeConfig reconciles the existing appsettings.json instead. Two costs. A credential was handled - and formatted onto a deferred CA command line - on a path that would never read it, which is the wrong default for a product whose posture is about keeping that secret contained. And blank fields on an upgrade read as impending data loss, pushing the operator toward the one move that wastes the most time. InstallDirDlg/Next now routes to VerifyReadyDlg when WIX_UPGRADE_DETECTED is set, and the config route is gated off. Both conditions are explicit rather than relying on higher-Order-wins, and on an upgrade the stock Order 4 publish points at VerifyReadyDlg too, so the destination no longer depends on that behaviour at all. VerifyReadyDlg/Back had to be gated as well. It was conditioned on NOT Installed, which is TRUE during a major upgrade - the new ProductCode is not installed yet - so Back walked into AdvancedOptionsDlg and left the operator on JenkinsConfigDlg, blocked by RequiredFieldDlg on fields the upgrade will not use. With the gate added, the stock Order 1 publish takes Back to InstallDirDlg. InstallDirDlg is deliberately still shown on an upgrade: without an AppSearch for the installed location the package genuinely does not know it, and skipping the page would silently relocate a non-default install. That is the larger change the issue describes and is left as follow-up. Verification. Test-MsiLifecycle.ps1 now runs the upgrade with no properties at all - no URL, no DATAFOLDER, no JENKINS_SECRET - which is also the stronger assertion, since supplying the same values on both runs made a preserved value indistinguishable from a re-written one. Url and DataDirectory are asserted alongside the secret for the same reason. A /quiet job never renders a dialog, so the gating itself is checked by reading the navigation graph back out of the package: Get-MsiControlEvent (new in MsiQuery.psm1) reads ControlEvent, and the test asserts every NewDialog edge into the config pages from outside them carries NOT WIX_UPGRADE_DETECTED - and that at least one exists, since this fragment is pulled in by a UIRef and the linker has silently dropped it before. Closes #81 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gate x64 <-> x86 migration At runtime nothing looks these up: the install folder is AppContext.BaseDirectory and the data folder comes from Jenkins:Agent:DataDirectory in the appsettings.json beside it. That chain only works once you already hold the exe, and an upgrade is upstream of it - Windows Installer has to decide where to PUT the exe first. The package recorded nothing to decide with, so INSTALLFOLDER and DATAFOLDER reset to their defaults on every upgrade. Two consequences, neither cosmetic. A non-default install folder was silently relocated to Program Files, and since appsettings.json is written by a custom action rather than installed as a tracked file it stayed behind - the new install had no config, UpgradeConfig had nothing to reconcile, and the only copy of the secret sat at the old path. A non-default DATAFOLDER made the DataFolderAcl component create and ACL a stray %ProgramData%\JenkinsAsService, granting the service account write on a folder nothing used; the real one kept working, so nothing failed loudly, and uninstall's purge reads DataDirectory out of the config so the stray outlived the product. Both locations are now recorded under HKLM\SOFTWARE\JenkinsAsService\x64 or ...\x86 and searched for at the start of the next install. WHICH key holds the paths is itself the statement of the installed architecture, so there is no separate value to go stale and regedit tells the whole story. Every package reads both keys - x64 in the 64-bit view, x86 in the 32-bit one - with a ComponentSearch fallback for products installed before these keys existed. Applied by type-51 actions at 51-60, after AppSearch (50) and before CostFinalize (1000). Precedence is first-match-wins, encoded by the NOT INSTALLFOLDER clause each one carries - which doubles as the override guard, because before CostFinalize a directory property holds a value only if it came from the command line. An operator who names a path still gets it. The migration gate. x64 and x86 share an UpgradeCode, so either replaces the other and FindRelatedProducts cannot tell them apart. That is not what someone who grabbed the wrong file meant, and there is no undo once RemoveExistingProducts has run. It now requires FORCE_UPGRADE=1 - MSI takes properties rather than dashed flags - identically in the wizard and in a silent install. BlockArchMigration is a type-19 error action at sequence 60, far ahead of InstallInitialize (1500), so a refused run aborts before the installed product is touched at all. Forced, it lands on the recovered folders, which is required rather than tidy: a migration that moved would strand the config exactly as an upgrade did. InstallLocationRegistry carries a different GUID per architecture. The two write different keys in different views, and component GUIDs are what Windows Installer reference-counts - one shared GUID would install the new component and merely decrement the old, orphaning the previous architecture's key, after which the next package would see two architectures installed and block against a product that no longer exists. ServiceComponent and DataFolderAcl keep a shared GUID for the opposite reason: same resource, same path, and that is what lets the ComponentSearch fallback find an install of either architecture. Testing. Test-MsiLifecycle asserts the locations are recorded, survive the upgrade, are recorded under one architecture's key only, and are removed by uninstall - plus that no stray default data folder appears, which is the flagged bug stated as an assertion. Test-MsiArchMigration is new and asserts both halves of the gate: refused without the flag AND inert (product still x64, service still running, config untouched), then accepted with it and preserving the folders, config, operator edits and secret. Asserting either half alone would pass a gate that never blocks or one that blocks everything. The x86 build suppresses ICE80 and nothing else, scoped to that platform: a 32-bit package reading the 64-bit view is exactly the behaviour the search exists for, and the x64 build keeps the full ICE suite. Not verified against a real machine: no MSI was installed locally. The authoring is verified by reading the built packages' Registry, RegLocator, CompLocator, CustomAction, Component and sequence tables; the install behaviour itself is first exercised by CI. Refs #81 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…winget manifests A single .msi cannot pick its own architecture. An MSI's Summary Information Template field carries exactly one platform token - the field Test-MsiPlatform.ps1 already reads to prove the two packages differ - a 32-bit package cannot install 64-bit components, and ProgramFiles64Folder is not addressable from one. The container cannot express it, so the selection moves one level up: a Burn bundle that embeds both MSIs and installs the one matching the machine. Both MSIs still ship, unchanged, for deployment tools that only speak MSI. Architecture policy is "always native, always migrate". The two MsiPackage entries are conditioned on VersionNT64 as exact complements, so the chain can neither install both nor install neither, and both are handed FORCE_UPGRADE=1. There is no reason to run the 32-bit agent on a 64-bit machine, so the bundle does not preserve a mismatched install - it migrates it, keeping the install folder, data folder, config and secret via the recovery added in the previous commit. The MSI gate stays strict for anyone running a bare .msi, where a wrong-architecture install is a mistake rather than a decision. bal:DisplayInternalUICondition keeps the MSI's own configuration wizard on interactive runs; the bundle has no UI for collecting a controller URL and a secret, and building one would mean a custom bootstrapper application to keep in sync with the MSI's pages. Settings are forwarded as overridable Burn variables into MsiProperty, every one defaulting to empty so the MSI's own defaults apply - two copies of a default is how they drift. JENKINS_SECRET and SERVICE_PASSWORD are Hidden and Persisted="no", so neither reaches Burn's registry-backed variable store, where it would outlive the install, nor its log. The payloads carry distinct Names. Both source files are called JenkinsAsService.Installer.msi, which collides inside the attached container: `wix burn extract` would overwrite one with the other and yield an extracted bundle holding two copies of one architecture. Test-Bundle.ps1 exists for one hazard specifically. PurgeInstallation is gated only on REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE, so if Burn ever planned the old architecture as a standalone uninstall rather than letting the incoming MSI's MajorUpgrade replace it, the purge would fire - and the migration would report success onto a machine whose configuration and agent secret had just been destroyed. Reasoning says it will not, because the two MSIs share an UpgradeCode and the chain's own x86 package carries a different ProductCode and is not detected as present. Reasoning is not evidence for a failure mode that looks like success, so CI installs the x86 MSI directly, runs the bundle over it, and asserts the config, an operator edit, the secret and the data folder all survived. The architecture that landed is read from the binary's PE header rather than from what the package claimed. The bundle's embedded MSIs are built at 1.0.3, above the 1.0.2 x86 baseline the test installs. Reusing the earlier x64 1.0.1 would have made the migration a downgrade, which MajorUpgrade correctly refuses - the suite would have failed on a version rule while appearing to say something about architecture. winget manifests are generated from the artifact itself, so the SHA256 cannot disagree with the file that ships, and attached to the release as a zip; opening the winget-pkgs PR stays a deliberate act. They point at the BUNDLE, listed under both architectures with the same URL, because winget selects by architecture and the bundle is the component that knows how to choose. Listing the MSIs directly would move that decision into winget, which would then offer the x64 package to a machine running the x86 install and produce a failed upgrade instead of a migration. Not verified against a real machine: nothing was installed locally. The bundle builds clean for both architectures and is 65.8 MB; the install, migration and uninstall behaviour is first exercised by CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e once Quality pass over the installer/bundle branch. No behaviour changes: the built packages were diffed against the previous authoring and carry identical sequence tables, component GUIDs, gate conditions and forwarded properties. WiX - the same fact stated twice. InstallLocation.wxs authored the recovery chain as ten <Custom> rows in InstallUISequence and ten byte-identical rows in InstallExecuteSequence, under a comment asking the next editor to keep them in sync. That is the weakest possible enforcement of the load-bearing part of the file: both the ordering and the first-match-wins conditions have to match, and a condition fixed in only one table gives a wizard install and a /quiet install different install folders, with nothing in CI comparing them. They are now eight SetProperty elements with Sequence="both", so WiX emits both rows from one authoring site. The explicit Action name is what lets several of them target one property without colliding on the generated id. BlockArchMigration stays per-sequence because an error custom action cannot take Sequence="both" - but it is two rows for one action now, with no conditions to mirror. The JAS_ARCH_MISMATCH custom action is gone. It set a flag read by exactly one consumer, so its clause folded into BlockArchMigration's own condition; the gate's real precondition is now readable in one place instead of by joining two conditions across two sequence blocks. The ComponentSearch GUIDs were literal copies of the component GUIDs in Service.wxs. Both now come from ComponentGuids.wxi, included by each file. This is the pairing most worth making structural: an edit to one side alone fails no build and no test, it silently makes the search find nothing, and the only thing that notices is an upgrade of an install predating the registry keys - which relocates and strands appsettings.json with the only copy of the secret. CI never installs such a version. Bundle.wxs forwarded twelve settings as MsiProperty rows written out per package. The two MsiPackage elements genuinely cannot be factored - different SourceFile, complementary InstallCondition, no loop over a Burn chain - but their contents can. The names are now one JasForwardedSettings list spliced into both by MsiProperties.wxi. Written per package, exactly one of which runs on any machine, an omission from the x86 list ships as "this setting silently does nothing on 32-bit hosts" and the bundle test only ever runs on a 64-bit runner. PowerShell - three copies of the same harness, already drifting. Test-MsiLifecycle, Test-MsiArchMigration and Test-Bundle had each grown their own Assert-That, failure epilogue, registry-key table, config reader and Start-Process wrapper. The copies had already disagreed: two Get-RecordedLocation signatures, three variants of the msiexec wrapper, and three separate re-implementations of the tail-the-log-on-failure block. They now share MsiTestHelpers.psm1. The registry layout matters most. Which key holds a path IS the statement of the installed architecture, and with per-script copies a renamed key makes Get-InstalledPlatform return $null everywhere - at which point an assertion of the form "the other architecture's key is absent" passes harder than before. It is stated once now. Likewise @(0, 3010) appeared seven times as a bare literal, once intentionally negated; "succeeded" is Test-InstallerSuccess. MsiQuery.psm1 keeps its promise to hold the COM interop once: OpenView/Execute/ Fetch/StringData lives in Invoke-MsiQuery, and the two public readers are a query plus a projection. Get-MsiControlEvent released nothing, so the package stayed open behind a live COM reference on a file handed to msiexec moments later; views are now closed and records released. The per-row scriptblock is hoisted. Get-MsiArchitecture and Get-MsiPlatformToken keep "a 32-bit package spells itself Intel" out of the four call sites that had re-derived it. New-WingetManifest.ps1 took the bundle UpgradeCode as an inline literal - a second copy of Bundle.wxs, in another language, with nothing comparing them. Rotate the code and the manifest silently ships a stale ARP identifier: winget stops recognising the installed package and upgrades fail on user machines while CI stays green. It is a defaulted parameter now. build.yml's four-rung version ladder was eight literals across `with:` values and step titles, its ordering constraints living only in prose. It is job-level env, so the rung a step builds and the rung its title claims cannot disagree. Verified: both MSIs and the bundle build with zero warnings; the sequence tables, CustomAction targets, CompLocator rows and Component GUIDs were read back out of the built packages and match the previous authoring; both bundle packages forward an identical 13-property set; the ControlEvent reader returns the same gated edges. PSScriptAnalyzer is clean (it was two warnings before this). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
WinRAR is proprietary trialware. There is no redistributable command-line licence covering unattended use in a public CI pipeline, so `choco install winrar` on a GitHub-hosted runner to produce artifacts for a BSD-licensed project was a licensing problem rather than a packaging choice. Removed rather than replaced. 7z stays and is unchanged: 7-Zip is free software, already present on the runner, and LZMA2 at -mx=9 compresses this payload better than RAR5 did anyway. The one thing genuinely lost is RAR's 5% recovery record. The release already publishes SHA256 checksums for every artifact, which covers detecting the corruption that record was there to repair - and a corrupt download is a re-download, not a recovery operation, for a 38 MB file. Releases now carry 8 files instead of 10; JenkinsAsService_<version>_x64.rar and _x86.rar are gone, and their entries are out of checksums.json. The docs that offered users a choice of .7z or .rar, the ci-cd pipeline description and the release-artifact table are updated to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…'s flake The PSScriptAnalyzer job failed on a branch whose PowerShell files are clean, with "Object reference not set to an instance of an object" thrown by Invoke-ScriptAnalyzer itself. It is not a finding and it is not the code: findings never fail this job, and every file analyzed individually is clean on 1.22.0 and 1.25.0 alike. It is a flake in the analyzer's directory traversal. Reproduced locally as 1 crash in 3 runs of `-Path <dir> -Recurse` over an unchanged tree, while the same files passed individually every time - and a copy of the same directory never crashed at all. `main` has been passing this job by luck, not because the tree is different. So the traversal goes away: the step enumerates the files itself and analyzes each by path, which skips the directory-walking and module-probing that trips the NRE. Verified stable across three consecutive runs of the exact new invocation. The retry covers whatever residue is left, scoped per file so one flake does not redo the set. It is deliberately bounded at three attempts and rethrows: the only way this job can fail is the analyzer throwing, and that has to stay loud rather than be swallowed into a green check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit found 0 files on the runner and failed on its own guard. Cause: `Get-ChildItem -Path <dir> -Include *.ps1` matches -Include against the path leaf and needs a trailing wildcard to work as intended. On Windows it returned the seven files; on the Linux runner it returned nothing, and I had only verified locally. -Filter is applied by the provider and behaves identically on both, so the enumeration is one pass per extension with the results deduplicated. The guard is the part that worked. "0 findings" and "analyzed nothing" are indistinguishable in the SARIF upload, so a filter matching nothing throws instead of reporting a clean scan - which is exactly how this surfaced in one run rather than silently disabling the job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third time on the same guard, and the actual cause: every PowerShell file in this repo lives under .github, which is dot-prefixed. POSIX treats a dot-prefixed directory as hidden, and Get-ChildItem -Recurse does not descend into hidden directories unless -Force is given. On Windows .github carries no hidden ATTRIBUTE, so it is enumerated either way. The platform difference is in the filesystem, not in PowerShell, which is why two rounds of adjusting the pattern matching changed nothing on the runner. Verified by marking a .github directory hidden locally: 0 files without -Force, 1 with. The guard held through all three attempts - it failed the job every time rather than uploading an empty SARIF that reads as a clean scan. That is the behaviour worth keeping; the original `-Path <dir> -Recurse` had no such check, so had it ever stopped finding files the job would have gone green while scanning nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… wrongly
The migration suite's first ever real run failed, and it was right to. A forced
x64 -> x86 migration aborted with 1603 out of UpgradeConfig, and the verbose log
named the cause:
WIN64DUALFOLDERS: 'C:\Program Files (x86)\' will substitute 17 characters in
'C:\Program Files\Jenkins\' folder path.
PROPERTY CHANGE: Adding JAS_INSTALLDIR_X64 property.
Its value is 'C:\Program Files (x86)\Jenkins\'
The always64 registry search is not the problem - it read the recorded
C:\Program Files\Jenkins\ correctly. Windows Installer then REWROTE the result:
in a 32-bit package on 64-bit Windows it maps the C:\Program Files\ prefix of any
folder-shaped AppSearch value into C:\Program Files (x86)\. So the x86 package
recovered a path the previous version never used, installed beside the real one,
and UpgradeConfig ran an exe with no appsettings.json beside it - exit 1, 1722,
1603, rollback. Had that CA been tolerant of a missing config, the migration would
have "succeeded" while stranding the only copy of the agent secret at the old path.
DATAFOLDER was unaffected throughout: %ProgramData% is not part of a dual-folder
pair, which is why only the install folder was wrong.
BREAKING: the policy is now asymmetric.
x86 -> x64 supported, opt-in via FORCE_UPGRADE=1, unchanged.
x64 -> x86 refused outright. FORCE_UPGRADE does NOT override it, because there
is nothing to override with - the package cannot learn where the
previous install lives. An override would only be a documented way
to reach the broken path on purpose.
Nothing is lost. The bundle's policy is the machine's NATIVE architecture, so it
only ever needs the supported direction: x64 on a 64-bit machine, and on a 32-bit
machine no x64 install can exist. Deliberately moving a machine to the 32-bit agent
is an uninstall plus a fresh install, which is honest - the config has to be
re-created at the new location either way.
The x86 package now omits RecoverInstallDirFromX64 entirely rather than carrying a
recovery whose value it cannot trust, and only the override TERM of the gate
condition differs per architecture, from a <?define?> holding plain text (XML
escapes inside a define arrive at the condition as literal "<" and fail ICE03 -
learned the hard way).
Test-MsiArchMigration.ps1 is rewritten around the real policy and now asserts the
half that was missing: that x86-over-x64 is refused WITH FORCE_UPGRADE as well as
without, and that each refusal is inert. The supported direction is asserted end to
end - refused without the flag, and with it landing on the ORIGINAL install and
data folders, with the config, the operator's edit and the secret intact and the
architecture confirmed from the installed binary's PE header rather than from what
the package claimed. It needs three packages in a strict version ladder, so it
reuses the bundle's x64 package and hard-stops if the ladder is ever wrong.
Test-Bundle.ps1 carried a latent bug of the same family: it looked for the x86
baseline under the 64-bit Program Files, where an x86 package never installs. It
had never run, because the arch suite fails first. Fixed, and it now also asserts
the migration preserved that folder rather than relocating to the x64 default.
Both suites only ever ran for the first time on this PR: build.yml triggers on
pull_request and pushes to main, so nothing exercised them while the feature was
being written on the branch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Nine commits on the installer. Three fix defects an operator hits on a real
upgrade, one adds the single-file installer, and the rest are cleanup plus two
things CI caught the moment it was finally able to run these paths.
An upgrade no longer re-prompts for the config or the secret (474325e)
The wizard showed the configuration pages on upgrade, which meant demanding the
controller URL and the agent secret (
RequiredFieldDlgblocks Next until bothare filled in) and then discarding both, because
WriteConfig/WriteAdvanced1-3/SetDataDirare all gatedNOT WIX_UPGRADE_DETECTED.InstallDirDlg/Nextnowroutes straight to
VerifyReadyDlgon upgrade andVerifyReadyDlg/Backstandsdown.
Installedis false during a major upgrade, soNOT Installeddoes not gatethe upgrade path —
WIX_UPGRADE_DETECTEDis the discriminator, in the UI exactlyas in the execute sequence.
The installer remembers where the last version went (6656645) — BREAKING
Windows Installer has to decide where to put the exe before anything can be read
back, and the package recorded nothing to decide with, so
INSTALLFOLDERandDATAFOLDERboth reset to defaults on every upgrade:appsettings.json— a CA-written file, not a tracked one — with the only copy ofthe secret at the old path;
DATAFOLDERmadeDataFolderAclcreate and ACL a stray%ProgramData%\JenkinsAsService, which uninstall's purge (it readsDataDirectoryfrom the config) then left behind forever.Both are now recorded under
HKLM\SOFTWARE\JenkinsAsService\x64or...\x86, andwhich key holds them is the statement of the installed architecture — no
separate
Platformvalue that can go stale.One downloadable file that installs the right architecture (0b44474)
JenkinsAsService_<version>.exeis now the primary installer: a Burn bundleembedding both MSIs, so an offline install cannot fail halfway. A single
.msicannot do this — the Summary Information
Templatecarries exactly one platformtoken, a 32-bit package cannot install 64-bit components, and
ProgramFiles64Folderis not addressable from one.Both MSIs still ship. winget manifests are generated from the artifact itself and
attached to the release; opening the
winget-pkgsPR stays a deliberate act.Cleanup pass (917b90a)
No behaviour change — the built packages were diffed against the previous
authoring, table by table. The recovery chain was ten
Customrows duplicatedacross
InstallUISequenceandInstallExecuteSequenceunder a comment asking thenext editor to keep them in sync; it is now
SetProperty Sequence="both". TheComponentSearchGUIDs and the bundle's 12 forwarded properties were literalcopies; both come from a shared
.wxi. The three real-install suites had eachgrown their own copy of the same assertion harness, registry-key table and msiexec
wrapper — already drifted into two
Get-RecordedLocationsignatures and threelog-tail implementations — now shared via
MsiTestHelpers.psm1.WinRAR archives dropped (b1ddf0b)
WinRAR is proprietary trialware with no redistributable CLI licence for unattended
public CI, so producing release artifacts with it for a BSD-licensed project was a
licensing problem. 7z is unchanged; the SHA256 checksums cover what RAR's recovery
record stood in for. Releases carry 8 files instead of 10.
x64 to x86 migration is now refused outright (c57c4e9) — BREAKING
This is the one worth reading. The migration suite's first ever real run failed,
and it was right to. A forced x64 to x86 migration aborted with 1603 from
UpgradeConfig, and the verbose log named the cause:The
always64search is not the problem — it read the recordedC:\Program Files\Jenkins\correctly. Windows Installer then rewrote theresult: in a 32-bit package on 64-bit Windows it maps the
C:\Program Files\prefix of any folder-shaped
AppSearchvalue toC:\Program Files (x86)\. So thex86 package recovered a path the previous version never used, installed beside the
real one, and
UpgradeConfigran an exe with no config next to it. Had that CAtolerated a missing config, the migration would have "succeeded" while stranding
the only copy of the agent secret at the old path.
The policy is therefore asymmetric, because the two directions are not equally safe:
FORCE_UPGRADE=1. Install folder, data folder, config and secret all carried across.FORCE_UPGRADEdoes not override it — there is nothing to override with, since the package cannot learn where the previous install lives.Nothing is lost. The bundle's policy is the machine's native architecture, so it
only ever needs the supported direction: x64 on a 64-bit machine, and on a 32-bit
machine no x64 install can exist. Deliberately moving a machine to the 32-bit agent
is an uninstall plus a fresh install, which is honest — the config would have to be
re-created at the new location either way.
DATAFOLDERwas unaffected throughout:%ProgramData%is not part of a dual-folderpair, which is why only the install folder was wrong.
PSScriptAnalyzer job made deterministic (9d42d3b, a446673, a2c8c27)
The job failed on a tree whose PowerShell files are clean, with an
Object reference not set to an instance of an objectthrown byInvoke-ScriptAnalyzeritself — reproduced locally as 1 crash in 3 runs of-Path <dir> -Recurseover an unchanged tree, on 1.22.0 and 1.25.0 alike. It nowanalyzes an explicit file list (skipping the traversal that trips the NRE) with a
bounded per-file retry.
Two follow-ups, both my own errors and both caught by the guard rather than
shipped:
-Includewith a directory-Pathbehaves differently on Linux, and.githubis dot-prefixed — POSIX treats that as hidden, so-Recurseskipped itentirely without
-Force. The guard is the part that worked: a filter matchingnothing throws, because "0 findings" and "analyzed nothing" are indistinguishable
in the SARIF upload, and the second is how a scan quietly stops scanning.
Verification
These paths used to be manual-verify. They are not any more, and that is what
turned up the WIN64DUALFOLDERS defect:
msi-lifecycleinstalls, upgrades in place, and uninstalls for real, assertingconfig preservation/add/prune, that an upgrade keeps
%ProgramData%and thatuninstall removes it. It hard-stops if the two packages share a
ProductVersion, since msiexec would then reconfigure and every assertion wouldstill pass.
Test-MsiArchMigration.ps1asserts both directions: x86-over-x64 refusedwith
FORCE_UPGRADEas well as without, each refusal inert; then x86 to x64refused without the flag and, with it, landing on the original folders with
the config, an operator edit and the secret intact — architecture confirmed from
the installed binary's PE header, not from what the package claimed.
Test-Bundle.ps1exists for one hazard:PurgeInstallationis gated only onREMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE, so if Burn ever planned the oldarchitecture as a standalone uninstall, the purge would fire and the migration
would "succeed" onto a machine whose config and secret had just been destroyed.
ControlEventtable, sincea
/quietjob never renders a dialog.Locally: both MSIs and the bundle build with zero warnings, and the sequence tables,
CustomActiontargets,CompLocatorrows and component GUIDs were read back out ofthe built packages to confirm the cleanup changed nothing.
🤖 Generated with Claude Code