Skip to content

Add GNU Privacy Guard as a Windows FMA - #50025

Merged
allenhouchins merged 4 commits into
mainfrom
add-gnupg-windows-fma
Jul 29, 2026
Merged

Add GNU Privacy Guard as a Windows FMA#50025
allenhouchins merged 4 commits into
mainfrom
add-gnupg-windows-fma

Conversation

@kitzy

@kitzy kitzy commented Jul 28, 2026

Copy link
Copy Markdown
Member

Related issue: #50020

What this does

Adds GNU Privacy Guard as a Windows Fleet-maintained app. One of the 11 apps split out of #48501 that failed the FMA validator; #50016 shipped the 6 that passed.

Why it was failing

The install itself worked — the validator logged New application detected at: C:\Program Files\GnuPG. The script never returned:

20:18:36  INFO  msg="Executing install script..." app="GNU Privacy Guard"
20:28:36  ERROR msg="Error executing install script: exit status 1"   # exactly 10:00 later
20:28:36  INFO  msg="New application detected at: C:\Program Files\GnuPG"

Ten minutes on the nose is the validator's executeScript timeout. The cause is a PowerShell detail rather than anything wrong with the installer: Start-Process -Wait waits for the process and all of its descendants. GnuPG's installer starts gpg-agent, dirmngr, keyboxd and scdaemon and leaves them resident, so -Wait never returns. The same run left the installer .exe locked in the validator's temp dir, which is the other tell that a child process was still alive.

The install script now follows the pattern already established by ollama_install.ps1: start with -PassThru (no -Wait), wait on the installer process alone with a 7-minute cap (below the caller's 10-minute script budget), poll for the Add/Remove Programs entry so a fast-returning installer can't be mistaken for a finished one, then stop the daemons.

Stopping the daemons also fixes the uninstall, which would otherwise fail on files those processes hold open. The uninstall script stops them up front, uses NSIS's _?=<dir> switch so the uninstaller runs in place instead of relaunching itself detached from %TEMP%, and polls the ARP key to confirm removal.

Notes

  • Clean ARP DisplayName (GNU Privacy Guard), so exact name matching — no fuzzy_match_name needed. Publisher The GnuPG Project.
  • Ships a new catalog icon and website asset.

Checklist for submitter

  • Input data is properly validated, SELECT * is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters.
  • Timeouts are implemented and retries are limited to avoid infinite loops

Testing

  • FMA CI validator (install → detect → uninstall) passes on the SYSTEM-context Windows runner — run 30384069714 (All checks passed)
  • Generated output verified locally: manifest SHA matches the winget manifest, exists/patched queries reviewed for name + publisher correctness, apps.json is valid JSON with a description filled in.
  • QA'd all new/changed functionality manually

Summary by CodeRabbit

  • New Features
    • Added GNU Privacy Guard as a supported Windows application in the maintained apps catalog.
    • Added install/upgrade detection and uninstall support for Windows.
    • Added GNU Privacy Guard to the software catalog (Security category).
    • Added a dedicated GNU Privacy Guard icon to the software interface for proper name-based display.

The installer leaves gpg-agent, dirmngr, keyboxd and scdaemon resident.
PowerShell's Start-Process -Wait waits for descendants as well as the process
itself, so the install script never returned and the validator killed it at its
10-minute cap. Wait on the installer process only, poll for the ARP entry, then
stop the daemons -- which also unlocks the files the uninstaller needs.
Copilot AI review requested due to automatic review settings July 28, 2026 00:05
@kitzy kitzy mentioned this pull request Jul 28, 2026
5 tasks
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 68.03%. Comparing base (b64fdaa) to head (77eb9c6).
⚠️ Report is 32 commits behind head on main.

Files with missing lines Patch % Lines
...tend/pages/SoftwarePage/components/icons/Gnupg.tsx 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #50025      +/-   ##
==========================================
+ Coverage   67.97%   68.03%   +0.06%     
==========================================
  Files        3922     3930       +8     
  Lines      250032   250268     +236     
  Branches    13334    13431      +97     
==========================================
+ Hits       169949   170273     +324     
+ Misses      64781    64690      -91     
- Partials    15302    15305       +3     
Flag Coverage Δ
frontend 60.88% <50.00%> (+0.47%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds GNU Privacy Guard (GnuPG) as a Windows Fleet-maintained app (FMA), including install/uninstall PowerShell scripts designed to avoid the validator timeout caused by lingering background processes, plus catalog/software UI icon support.

Changes:

  • Adds Winget input metadata and new install/uninstall scripts for the gnupg/windows FMA.
  • Adds generated maintained-app outputs (windows.json entry + apps.json catalog entry) for GNU Privacy Guard.
  • Adds a new software icon component and maps the software name to that icon in the frontend.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
frontend/pages/SoftwarePage/components/icons/index.ts Registers the GNU Privacy Guard name → icon mapping.
frontend/pages/SoftwarePage/components/icons/Gnupg.tsx Adds the Gnupg icon component (embedded image).
ee/maintained-apps/outputs/gnupg/windows.json Generated Windows FMA output manifest including scripts and detection queries.
ee/maintained-apps/outputs/apps.json Adds GNU Privacy Guard to the maintained apps catalog list.
ee/maintained-apps/inputs/winget/gnupg.json New Winget input definition for the gnupg/windows FMA.
ee/maintained-apps/inputs/winget/scripts/gnupg_install.ps1 Install script using bounded wait + ARP polling + daemon stop.
ee/maintained-apps/inputs/winget/scripts/gnupg_uninstall.ps1 Uninstall script using daemon stop + NSIS in-place uninstall + ARP polling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1 to +5
{
"name": "GNU Privacy Guard",
"slug": "gnupg/windows",
"package_identifier": "GnuPG.GnuPG",
"unique_identifier": "GNU Privacy Guard",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does — run 30372668284, All checks passed (install → detect → uninstall on the SYSTEM-context Windows runner). The linked run is recorded in the PR description's testing checklist.

Comment on lines +30 to +32
# Touch .Handle so the exit code is still readable after the process ends:
# Start-Process -PassThru otherwise returns $null for .ExitCode.
$null = $process.Handle

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, the wording overstates it. Touching .Handle is a real and widely used guard for Start-Process -PassThru (the object can lose access to the exit code once the process ends), but it isn't the unconditional behavior the comment asserts, and ollama_install.ps1 reads .ExitCode without it.

I've left the guard in place — it's harmless and defensive — but I'm deliberately not rewording it across all seven PRs right now, since each edit costs a ~12-minute validator run for a comment-only change. Happy to do a wording-only pass across the batch if you'd prefer it before merge.

Comment on lines +59 to +61
# Touch .Handle so the exit code is still readable after the process ends:
# Start-Process -PassThru otherwise returns $null for .ExitCode.
$null = $process.Handle

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the install script — see the reply there. The guard stays; the wording is over-stated and I'd rather batch a comment-only pass than spend a validator run per PR on it.

The installer never exits on a headless machine and killing it leaves a
half-finished install: inst.nsi writes the ARP entry in its last hidden section,
so the first attempt produced files on disk with no registry entry. The stall is
a MessageBox with no /SD default -- most likely the GpgEX regsvr32 failure -- so
close the installer's window instead and let the script run to completion. Also
check HKCU, since inst.nsi writes uninstall info with SHCTX.
Copilot AI review requested due to automatic review settings July 28, 2026 00:28
kitzy added a commit that referenced this pull request Jul 28, 2026
Same fix as GnuPG (#50025), which Gpg4win bundles: the installer stalls on a
MessageBox with no /SD default -- most likely the GpgEX regsvr32 failure -- and
the ARP entry is written in a late section, so killing the installer leaves a
half-finished install. Close the window instead and let it run to completion.
@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/gnupg/windows.json

=== Install // 7bc1373b -> 6009e287 ===

--- /tmp/old.7ZILO6	2026-07-28 00:30:17.439033089 +0000
+++ /tmp/new.bUtvDD	2026-07-28 00:30:17.439033089 +0000
@@ -3,21 +3,38 @@
 
 $exeFilePath = "${env:INSTALLER_PATH}"
 
-# GnuPG's installer starts its background daemons (gpg-agent, dirmngr, keyboxd,
-# scdaemon) as part of the install and leaves them resident. PowerShell's
-# "Start-Process -Wait" waits for the process *and all of its descendants*, so
-# those daemons keep the install script blocked indefinitely. Start without
-# -Wait, wait on the installer process itself with a timeout, then stop the
-# daemons so the script can return -- the same approach ollama_install.ps1 uses.
-$daemons = @("gpg-agent", "dirmngr", "keyboxd", "scdaemon", "gpg-connect-agent", "gpgconf")
-$installTimeoutSeconds = 300
-$registrationTimeoutSeconds = 120
+# The GnuPG installer process does not exit on its own on a headless machine, and
+# killing it is not enough: its NSIS script writes the Add/Remove Programs entry
+# in the very last, hidden section (inst.nsi -> DisplayName "GNU Privacy Guard"
+# under ...\Uninstall\GnuPG), so an installer stopped part-way leaves files on
+# disk with no registry entry and nothing for inventory to match.
+#
+# What stalls it is a modal dialog. inst.nsi has several MessageBox calls with no
+# /SD default -- most relevantly the GpgEX shell-extension registration failure
+# ("regsvr32 /s gpgex6.dll"), which is exactly the kind of thing that fails with
+# no interactive desktop. With nobody to click OK, the installer waits forever.
+#
+# So: leave the installer's children alone (killing regsvr32 would *guarantee*
+# that dialog), and instead close any window the installer puts up. Closing an
+# MB_OK dialog is equivalent to acknowledging it, and the install then runs on to
+# the section that writes the registry entry. Each poll also logs what is on
+# screen and which children are alive, so a future failure is diagnosable from
+# the CI log alone.
+$daemons = @("gpg-agent", "dirmngr", "keyboxd", "scdaemon", "gpg-connect-agent", "gpgconf", "gpa", "launch-gpa")
+$installTimeoutSeconds = 420
+$pollSeconds = 10
+# Let the installer get on with it before we start closing windows, so a dialog
+# that is genuinely transient isn't dismissed prematurely.
+$graceSeconds = 30
 
 $machineKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
 $machineKey32on64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
+# inst.nsi writes the uninstall info with SHCTX, so check the per-user hive too in
+# case the installer resolves to current-user mode.
+$userKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
 
 function Test-GnuPGRegistered {
-    $null -ne (Get-ChildItem -Path @($machineKey, $machineKey32on64) -ErrorAction SilentlyContinue |
+    $null -ne (Get-ChildItem -Path @($machineKey, $machineKey32on64, $userKey) -ErrorAction SilentlyContinue |
         ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |
         Where-Object { $_.DisplayName -like "GNU Privacy Guard*" } |
         Select-Object -First 1)
@@ -31,39 +48,49 @@
 # Start-Process -PassThru otherwise returns $null for .ExitCode.
 $null = $process.Handle
 
-if (-not $process.WaitForExit($installTimeoutSeconds * 1000)) {
-  Write-Host "Installer process did not exit within ${installTimeoutSeconds}s, stopping it."
-  Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
-  Start-Sleep -Seconds 2
+$elapsed = 0
+while (-not $process.HasExited -and ($elapsed -lt $installTimeoutSeconds)) {
+  Start-Sleep -Seconds $pollSeconds
+  $elapsed += $pollSeconds
+  $process.Refresh()
+  if ($process.HasExited) { break }
+
+  $children = @(Get-Process -Name $daemons -ErrorAction SilentlyContinue |
+    Select-Object -ExpandProperty Name -Unique)
+  $windowTitle = ""
+  try { $windowTitle = $process.MainWindowTitle } catch { }
+
+  Write-Host "Installing... ($elapsed seconds, registered: $(Test-GnuPGRegistered), window: '$windowTitle', children: $($children -join ', '))"
+
+  if ($elapsed -ge $graceSeconds -and $process.MainWindowHandle -ne [IntPtr]::Zero) {
+    Write-Host "Installer is showing a window ('$windowTitle'); closing it so the install can continue."
+    $null = $process.CloseMainWindow()
+  }
 }
 
-$exitCode = $process.ExitCode
-Write-Host "Install exit code: $exitCode"
-
-# The installer can return before the Add/Remove Programs entry is written; wait
-# for it so software inventory sees a complete install.
-$elapsed = 0
-while (-not (Test-GnuPGRegistered) -and ($elapsed -lt $registrationTimeoutSeconds)) {
-  Start-Sleep -Seconds 5
-  $elapsed += 5
-  Write-Host "Waiting for GnuPG to register... ($elapsed seconds)"
+if (-not $process.HasExited) {
+  Write-Host "Installer still running after ${installTimeoutSeconds}s; stopping it."
+  Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
+  Start-Sleep -Seconds 2
+} else {
+  Write-Host "Install exit code: $($process.ExitCode)"
 }
 
 # Stop the resident daemons the installer started. Leaving them running holds
 # file locks that make a later uninstall fail.
-foreach ($daemon in $daemons) {
-  Stop-Process -Name $daemon -Force -ErrorAction SilentlyContinue
+foreach ($name in $daemons) {
+  Stop-Process -Name $name -Force -ErrorAction SilentlyContinue
 }
 
+# Registration is the success signal, not the exit code: on the timeout path the
+# installer was killed, so its exit code says nothing about the install.
 if (-not (Test-GnuPGRegistered)) {
   Write-Host "GnuPG did not register in Add/Remove Programs."
   Exit 1
 }
 
-# 3010 (reboot required) and 1641 (reboot initiated) are successful installs.
-if ($exitCode -eq 3010 -or $exitCode -eq 1641) { Exit 0 }
-
-Exit $exitCode
+Write-Host "GnuPG is registered in Add/Remove Programs."
+Exit 0
 
 } catch {
   Write-Host "Error: $_"

=== Uninstall Script (no changes) ===

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 2 comments.

Comment on lines +11 to +16
$machineKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
$machineKey32on64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
$exitCode = 0

function Get-GnuPGUninstallKey {
Get-ChildItem -Path @($machineKey, $machineKey32on64) -ErrorAction SilentlyContinue |

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and a real inconsistency I introduced — fixed. The uninstall lookup now searches HKCU and HKCU\Wow6432Node alongside the HKLM keys, mirroring the install script.

The install script checks HKCU because inst.nsi writes the uninstall info with SHCTX, so it genuinely can land per-user. The uninstall also now requires Publisher -eq 'The GnuPG Project', which is verified against that same upstream source: WriteRegStr SHCTX $MYTMP "Publisher" "The GnuPG Project". run 30372668284 passes.

Comment on lines +49 to +53
# "_?=<dir>" runs it in place instead, which makes it synchronous. It must be
# the last argument and must not be quoted, so pass one argument string rather
# than an array (PowerShell would quote an element containing spaces).
$installDir = Split-Path -Parent $uninstallCommand
$uninstallArgs = "/S _?=$installDir"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving the single-string form, but the misleading comment is fixed.

The repo doesn't actually have one convention here: bdash, binance, biscuit and canva pass @("/S", "_?=$installDir"), while android_studio passes it quoted as _?="$installDir". The single-string form is what's been validated end-to-end for this installer against a path containing a space (C:\Program Files\GnuPG), so I'd rather not swap a CI-proven form for a stylistic one. The comment no longer asserts that quoting is impossible, and now points out that some NSIS uninstallers reject _?= outright — DBeaver's returns exit 2, Logitech Unifying's exit 10.

Addresses Copilot's review. The install script accounts for inst.nsi writing
uninstall info with SHCTX (so it can land in HKCU) but the uninstall only searched
HKLM -- if the entry ever landed per-user the uninstall would fail. Also requires
the publisher, verified against the upstream NSIS source
('WriteRegStr SHCTX $MYTMP "Publisher" "The GnuPG Project"'), which puts the
exists-query value under test since appExists matches on name only.

Exit 0 when no entry is found, per repo convention, and corrected the _?= comment:
it claimed a constraint that contradicts other scripts here, several of which pass
it as an array element or quoted.
Copilot AI review requested due to automatic review settings July 28, 2026 15:17
@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/gnupg/windows.json

=== Install Script (no changes) ===
=== Uninstall // b38846cc -> c398d50b ===

--- /tmp/old.vDZj4Y	2026-07-28 15:19:11.553414713 +0000
+++ /tmp/new.w3DKjo	2026-07-28 15:19:11.553414713 +0000
@@ -1,4 +1,11 @@
 $softwareName = "GNU Privacy Guard"
+# Require the publisher too, mirroring the manifest's exists query. Verified
+# against the upstream NSIS source, which writes it literally:
+#   WriteRegStr SHCTX $MYTMP "Publisher" "The GnuPG Project"   (build-aux/speedo/w32/inst.nsi)
+# Requiring it here also puts the value under test -- the validator's appExists
+# looks up by name only, so a wrong exists-query publisher would otherwise ship
+# undetected (cf. the Spyder finding in #50016).
+$softwarePublisher = "The GnuPG Project"
 
 # GnuPG leaves background daemons resident (gpg-agent, dirmngr, keyboxd,
 # scdaemon). They hold file locks that make the uninstall fail, and because
@@ -10,12 +17,17 @@
 
 $machineKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
 $machineKey32on64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
+# inst.nsi writes the uninstall info with SHCTX, so it can land in the per-user
+# hive. The install script already accounts for that; mirror it here so the
+# uninstall can still find the entry.
+$userKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
+$userKey32on64 = 'HKCU:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
 $exitCode = 0
 
 function Get-GnuPGUninstallKey {
-    Get-ChildItem -Path @($machineKey, $machineKey32on64) -ErrorAction SilentlyContinue |
+    Get-ChildItem -Path @($machineKey, $machineKey32on64, $userKey, $userKey32on64) -ErrorAction SilentlyContinue |
         ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |
-        Where-Object { $_.DisplayName -like "$softwareName*" } |
+        Where-Object { $_.DisplayName -like "$softwareName*" -and $_.Publisher -eq $softwarePublisher } |
         Select-Object -First 1
 }
 
@@ -26,8 +38,10 @@
 try {
     $key = Get-GnuPGUninstallKey
     if (-not $key) {
-        Write-Host "Uninstaller for '$softwareName' not found."
-        Exit 1
+        # Nothing to remove is not a failure: uninstall scripts are idempotent here,
+        # as in nordpass_uninstall.ps1 and windsurf_uninstall.ps1.
+        Write-Host "Uninstall entry not found for '$softwareName'."
+        Exit 0
     }
 
     $uninstallString = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
@@ -46,9 +60,12 @@
 
     # NSIS uninstallers copy themselves to %TEMP% and relaunch by default, so the
     # process we start exits immediately while the real uninstall runs detached.
-    # "_?=<dir>" runs it in place instead, which makes it synchronous. It must be
-    # the last argument and must not be quoted, so pass one argument string rather
-    # than an array (PowerShell would quote an element containing spaces).
+    # "_?=<dir>" runs it in place instead, which makes it synchronous, and it has
+    # to be the last argument. Other scripts here pass it as its own ArgumentList
+    # element (bdash, binance, canva) or quoted (android_studio); this passes one
+    # argument string, which is what was validated in CI for this installer. Note
+    # not every NSIS uninstaller accepts it -- DBeaver's returns exit 2 and
+    # Logitech Unifying's exit 10, so both of those omit it.
     $installDir = Split-Path -Parent $uninstallCommand
     $uninstallArgs = "/S _?=$installDir"

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

ee/maintained-apps/inputs/winget/scripts/gnupg_install.ps1:41

  • The post-install registry check (Test-GnuPGRegistered) only matches by DisplayName and doesn’t include the HKCU Wow6432Node uninstall hive. The output manifest’s exists query requires both name and publisher, and uninstall checks include HKCU Wow6432Node; aligning the install verification to the same identity reduces false positives/negatives and keeps install/uninstall behavior consistent.
$machineKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
$machineKey32on64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
# inst.nsi writes the uninstall info with SHCTX, so check the per-user hive too in
# case the installer resolves to current-user mode.
$userKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'

ee/maintained-apps/inputs/winget/scripts/gnupg_uninstall.ps1:75

  • The UninstallString parsing throws away any existing arguments (everything after the exe path). If the registry entry includes required flags (common for some uninstallers), they won’t be passed, and the uninstall can behave differently than intended. Preserve the existing args, ensure /S is present, and then append _?=<installDir> (as the last argument) when needed.
    if ($uninstallCommand -match '^\s*"([^"]+)"\s*(.*)$') {
        $uninstallCommand = $Matches[1]
    } elseif ($uninstallCommand -match '(?i)^\s*(.+?\.exe)\s*(.*)$') {
        $uninstallCommand = $Matches[1]
    } elseif ($uninstallCommand -match '^\s*(\S+)\s*(.*)$') {

@kitzy
kitzy marked this pull request as ready for review July 28, 2026 16:16
@kitzy
kitzy requested a review from a team as a code owner July 28, 2026 16:16
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds GNU Privacy Guard 2.5.21 as a maintained Windows application with metadata, validation, installer references, checksum, and category. PowerShell scripts manage silent installation and uninstallation, daemon processes, timeouts, and registry verification. The catalog includes the Windows entry, and SoftwarePage maps GNU Privacy Guard to a new icon.

Possibly related issues

  • Issue 50020 — Covers GNU Privacy Guard installation monitoring, modal-window handling, daemon cleanup, and registry-based validation.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main change: adding GNU Privacy Guard as a Windows Fleet-maintained app.
Description check ✅ Passed The description covers the issue, behavior change, notes, and testing, and it follows the required template closely.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch add-gnupg-windows-fma

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
frontend/pages/SoftwarePage/components/icons/Gnupg.tsx (1)

5-13: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Add viewBox to make this icon responsive.

Allowing callers to override width and height changes the SVG viewport, but the embedded image remains a fixed 32×32 user-space image. Add viewBox="0 0 32 32" so the image can scale with the root SVG.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/pages/SoftwarePage/components/icons/Gnupg.tsx` around lines 5 - 13,
Update the root svg in Gnupg to include viewBox="0 0 32 32", preserving the
existing 32×32 image dimensions and caller-provided width and height overrides
so the embedded image scales responsively.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ee/maintained-apps/inputs/winget/scripts/gnupg_install.ps1`:
- Around line 36-40: Use one exact ARP identity contract across both lifecycle
scripts: in Test-GnuPGRegistered, require DisplayName to equal "GNU Privacy
Guard" and Publisher to equal "The GnuPG Project" before reporting success; in
gnupg_uninstall.ps1 at lines 27-31, change the uninstall-entry selection to
require the exact "GNU Privacy Guard" display name instead of a prefix match.

In `@ee/maintained-apps/inputs/winget/scripts/gnupg_uninstall.ps1`:
- Around line 69-75: Update the uninstall argument construction in the gnupg
uninstall script to quote the directory value assigned to the NSIS `_?=` switch
before passing it to Start-Process. Preserve the existing silent `/S` argument
and ensure paths containing spaces remain a single argument.

---

Nitpick comments:
In `@frontend/pages/SoftwarePage/components/icons/Gnupg.tsx`:
- Around line 5-13: Update the root svg in Gnupg to include viewBox="0 0 32 32",
preserving the existing 32×32 image dimensions and caller-provided width and
height overrides so the embedded image scales responsively.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 14f498e2-cb3b-49be-8cb9-50cb61ad0dbe

📥 Commits

Reviewing files that changed from the base of the PR and between b64fdaa and 817f40b.

⛔ Files ignored due to path filters (1)
  • website/assets/images/app-icon-gnupg-60x60@2x.png is excluded by !**/*.png
📒 Files selected for processing (7)
  • ee/maintained-apps/inputs/winget/gnupg.json
  • ee/maintained-apps/inputs/winget/scripts/gnupg_install.ps1
  • ee/maintained-apps/inputs/winget/scripts/gnupg_uninstall.ps1
  • ee/maintained-apps/outputs/apps.json
  • ee/maintained-apps/outputs/gnupg/windows.json
  • frontend/pages/SoftwarePage/components/icons/Gnupg.tsx
  • frontend/pages/SoftwarePage/components/icons/index.ts

Comment on lines +36 to +40
function Test-GnuPGRegistered {
$null -ne (Get-ChildItem -Path @($machineKey, $machineKey32on64, $userKey) -ErrorAction SilentlyContinue |
ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |
Where-Object { $_.DisplayName -like "GNU Privacy Guard*" } |
Select-Object -First 1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use one exact ARP identity contract across lifecycle scripts. The manifest requires name = 'GNU Privacy Guard' and publisher The GnuPG Project, but both scripts use a prefix match.

  • ee/maintained-apps/inputs/winget/scripts/gnupg_install.ps1#L36-L40: require exact display name and publisher before declaring installation successful.
  • ee/maintained-apps/inputs/winget/scripts/gnupg_uninstall.ps1#L27-L31: require the exact display name before selecting the uninstall entry.
📍 Affects 2 files
  • ee/maintained-apps/inputs/winget/scripts/gnupg_install.ps1#L36-L40 (this comment)
  • ee/maintained-apps/inputs/winget/scripts/gnupg_uninstall.ps1#L27-L31
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ee/maintained-apps/inputs/winget/scripts/gnupg_install.ps1` around lines 36 -
40, Use one exact ARP identity contract across both lifecycle scripts: in
Test-GnuPGRegistered, require DisplayName to equal "GNU Privacy Guard" and
Publisher to equal "The GnuPG Project" before reporting success; in
gnupg_uninstall.ps1 at lines 27-31, change the uninstall-entry selection to
require the exact "GNU Privacy Guard" display name instead of a prefix match.

Comment thread ee/maintained-apps/inputs/winget/scripts/gnupg_uninstall.ps1
@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/gnupg/windows.json

=== Install // 6009e287 -> 8c2ff75e ===

--- /tmp/old.K6gCT1	2026-07-28 17:51:12.773684133 +0000
+++ /tmp/new.oFBlPH	2026-07-28 17:51:12.773684133 +0000
@@ -3,34 +3,17 @@
 
 $exeFilePath = "${env:INSTALLER_PATH}"
 
-# The GnuPG installer process does not exit on its own on a headless machine, and
-# killing it is not enough: its NSIS script writes the Add/Remove Programs entry
-# in the very last, hidden section (inst.nsi -> DisplayName "GNU Privacy Guard"
-# under ...\Uninstall\GnuPG), so an installer stopped part-way leaves files on
-# disk with no registry entry and nothing for inventory to match.
-#
-# What stalls it is a modal dialog. inst.nsi has several MessageBox calls with no
-# /SD default -- most relevantly the GpgEX shell-extension registration failure
-# ("regsvr32 /s gpgex6.dll"), which is exactly the kind of thing that fails with
-# no interactive desktop. With nobody to click OK, the installer waits forever.
-#
-# So: leave the installer's children alone (killing regsvr32 would *guarantee*
-# that dialog), and instead close any window the installer puts up. Closing an
-# MB_OK dialog is equivalent to acknowledging it, and the install then runs on to
-# the section that writes the registry entry. Each poll also logs what is on
-# screen and which children are alive, so a future failure is diagnosable from
-# the CI log alone.
+# The installer stalls on a modal dialog with no interactive desktop and never
+# exits. Closing its window lets it run through to the section that writes the
+# Add/Remove Programs entry; killing it instead would leave a partial install.
 $daemons = @("gpg-agent", "dirmngr", "keyboxd", "scdaemon", "gpg-connect-agent", "gpgconf", "gpa", "launch-gpa")
 $installTimeoutSeconds = 420
 $pollSeconds = 10
-# Let the installer get on with it before we start closing windows, so a dialog
-# that is genuinely transient isn't dismissed prematurely.
 $graceSeconds = 30
 
 $machineKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
 $machineKey32on64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
-# inst.nsi writes the uninstall info with SHCTX, so check the per-user hive too in
-# case the installer resolves to current-user mode.
+# Uninstall info is written with SHCTX, so it can land per-user.
 $userKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
 
 function Test-GnuPGRegistered {
@@ -42,10 +25,8 @@
 
 try {
 
-# GnuPG uses an NSIS installer.
 $process = Start-Process -FilePath "$exeFilePath" -ArgumentList "/S" -PassThru
-# Touch .Handle so the exit code is still readable after the process ends:
-# Start-Process -PassThru otherwise returns $null for .ExitCode.
+# Keeps .ExitCode readable after the process ends.
 $null = $process.Handle
 
 $elapsed = 0
@@ -76,14 +57,12 @@
   Write-Host "Install exit code: $($process.ExitCode)"
 }
 
-# Stop the resident daemons the installer started. Leaving them running holds
-# file locks that make a later uninstall fail.
+# Stop the resident daemons; they hold file locks the uninstall needs released.
 foreach ($name in $daemons) {
   Stop-Process -Name $name -Force -ErrorAction SilentlyContinue
 }
 
-# Registration is the success signal, not the exit code: on the timeout path the
-# installer was killed, so its exit code says nothing about the install.
+# Registration is the success signal: a killed installer's exit code says nothing.
 if (-not (Test-GnuPGRegistered)) {
   Write-Host "GnuPG did not register in Add/Remove Programs."
   Exit 1

=== Uninstall // c398d50b -> d1a2230f ===

--- /tmp/old.JMQirG	2026-07-28 17:51:12.802683810 +0000
+++ /tmp/new.UKiPaO	2026-07-28 17:51:12.802683810 +0000
@@ -1,25 +1,14 @@
 $softwareName = "GNU Privacy Guard"
-# Require the publisher too, mirroring the manifest's exists query. Verified
-# against the upstream NSIS source, which writes it literally:
-#   WriteRegStr SHCTX $MYTMP "Publisher" "The GnuPG Project"   (build-aux/speedo/w32/inst.nsi)
-# Requiring it here also puts the value under test -- the validator's appExists
-# looks up by name only, so a wrong exists-query publisher would otherwise ship
-# undetected (cf. the Spyder finding in #50016).
 $softwarePublisher = "The GnuPG Project"
 
-# GnuPG leaves background daemons resident (gpg-agent, dirmngr, keyboxd,
-# scdaemon). They hold file locks that make the uninstall fail, and because
-# "Start-Process -Wait" waits for descendants as well as the process itself,
-# anything the uninstaller re-spawns would block this script indefinitely. Stop
-# the daemons first, then wait only on the uninstaller process.
+# The daemons hold file locks and would block -Wait, so stop them first and wait
+# only on the uninstaller process.
 $daemons = @("gpg-agent", "dirmngr", "keyboxd", "scdaemon", "gpg-connect-agent", "gpgconf")
 $timeoutSeconds = 300
 
 $machineKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
 $machineKey32on64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
-# inst.nsi writes the uninstall info with SHCTX, so it can land in the per-user
-# hive. The install script already accounts for that; mirror it here so the
-# uninstall can still find the entry.
+# Uninstall info is written with SHCTX, so it can land per-user.
 $userKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
 $userKey32on64 = 'HKCU:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
 $exitCode = 0
@@ -38,8 +27,6 @@
 try {
     $key = Get-GnuPGUninstallKey
     if (-not $key) {
-        # Nothing to remove is not a failure: uninstall scripts are idempotent here,
-        # as in nordpass_uninstall.ps1 and windsurf_uninstall.ps1.
         Write-Host "Uninstall entry not found for '$softwareName'."
         Exit 0
     }
@@ -47,8 +34,7 @@
     $uninstallString = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
     Write-Host "Uninstall string: $uninstallString"
 
-    # Parse the executable path, handling quoted paths, unquoted paths containing
-    # spaces, and bare tokens.
+    # Handles quoted paths, unquoted paths with spaces, and bare tokens.
     $uninstallCommand = $uninstallString
     if ($uninstallCommand -match '^\s*"([^"]+)"\s*(.*)$') {
         $uninstallCommand = $Matches[1]
@@ -58,14 +44,8 @@
         $uninstallCommand = $Matches[1]
     }
 
-    # NSIS uninstallers copy themselves to %TEMP% and relaunch by default, so the
-    # process we start exits immediately while the real uninstall runs detached.
-    # "_?=<dir>" runs it in place instead, which makes it synchronous, and it has
-    # to be the last argument. Other scripts here pass it as its own ArgumentList
-    # element (bdash, binance, canva) or quoted (android_studio); this passes one
-    # argument string, which is what was validated in CI for this installer. Note
-    # not every NSIS uninstaller accepts it -- DBeaver's returns exit 2 and
-    # Logitech Unifying's exit 10, so both of those omit it.
+    # NSIS uninstallers relaunch from %TEMP% and detach by default; "_?=<dir>"
+    # runs in place so this stays synchronous. Must be the last argument.
     $installDir = Split-Path -Parent $uninstallCommand
     $uninstallArgs = "/S _?=$installDir"
 
@@ -73,8 +53,7 @@
     Write-Host "Uninstall args: $uninstallArgs"
 
     $process = Start-Process -FilePath $uninstallCommand -ArgumentList $uninstallArgs -PassThru
-    # Touch .Handle so the exit code is still readable after the process ends:
-    # Start-Process -PassThru otherwise returns $null for .ExitCode.
+    # Keeps .ExitCode readable after the process ends.
     $null = $process.Handle
 
     if (-not $process.WaitForExit($timeoutSeconds * 1000)) {
@@ -90,7 +69,7 @@
     Exit 1
 }
 
-# Stop anything the uninstaller restarted, then wait for the ARP entry to clear.
+# Stop anything restarted, then wait for the ARP entry to clear.
 foreach ($daemon in $daemons) {
     Stop-Process -Name $daemon -Force -ErrorAction SilentlyContinue
 }

@allenhouchins allenhouchins changed the title Add GNU Privacy Guard Windows FMA Add GNU Privacy Guard as a Windows FMA Jul 29, 2026
@allenhouchins
allenhouchins merged commit cfbb5a5 into main Jul 29, 2026
36 checks passed
@allenhouchins
allenhouchins deleted the add-gnupg-windows-fma branch July 29, 2026 03:56
allenhouchins pushed a commit that referenced this pull request Jul 31, 2026
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** #50020

# What this does

Adds **Gpg4win** as a Windows Fleet-maintained app. One of the 11 apps
split out of #48501 that failed the FMA validator; #50016 shipped the 6
that passed.

## Why it was failing

Same root cause as GNU Privacy Guard (#50025) — Gpg4win bundles GnuPG.
The install worked; the *script* never returned:

```
20:30:53  INFO  msg="Executing install script..." app=Gpg4win
20:40:53  ERROR msg="Error executing install script: exit status 1"   # exactly 10:00 later
20:40:53  INFO  msg="New application detected at: C:\Program Files\Gpg4win"
```

Ten minutes on the nose is the validator's `executeScript` timeout.
**`Start-Process -Wait` waits for the process *and all of its
descendants***, and Gpg4win leaves `gpg-agent`, `dirmngr`, `keyboxd` and
`scdaemon` resident (plus Kleopatra), so `-Wait` never returns. The same
run left `gpg4win-5.0.2.exe` locked in the validator's temp dir,
confirming a live child process.

The install script now follows the pattern already established by
[`ollama_install.ps1`](ee/maintained-apps/inputs/winget/scripts/ollama_install.ps1):
start with `-PassThru` (no `-Wait`), wait on the installer process alone
with a 7-minute cap (below the caller's 10-minute script budget), poll
for the Add/Remove Programs entry, then stop the leftovers.

The uninstall script stops those processes up front (they hold file
locks that make the uninstall fail), uses NSIS's `_?=<dir>` switch so
the uninstaller runs in place rather than relaunching itself detached
from `%TEMP%`, and polls the ARP key to confirm removal.

## Notes

- **Versioned ARP name.** The registry `DisplayName` is `Gpg4win
(5.0.2)`, so the input uses `fuzzy_match_name` and the exists query is
`name LIKE 'Gpg4win %'`. The uninstall script matches the same prefix.
- x86-only installer. Publisher `The Gpg4win Project`.
- Ships a new catalog icon and website asset.

# Checklist for submitter

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops

## Testing

- [x] FMA CI validator (install → detect → uninstall) **passes** on the
SYSTEM-context Windows runner — [run
30384125610](https://github.com/fleetdm/fleet/actions/runs/30384125610)
(`All checks passed`)
- [x] Generated output verified locally: manifest SHA matches the winget
manifest, exists/patched queries reviewed for name + publisher
correctness, `apps.json` is valid JSON with a description filled in.
- [x] QA'd all new/changed functionality manually





<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
  - Added Gpg4win as a supported Windows application.
  - Added Gpg4win version 5.0.2 with Security categorization.
  - Added a Gpg4win icon to the software interface.
- Introduced silent install and uninstall support with process cleanup,
timeouts, and registry-based verification to confirm install/removal
outcomes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants