Skip to content

Index every winget package for detection, not just the curated catalogue - #7

Merged
TecharyJames merged 3 commits into
BETAfrom
feat/full-detection-index
Sep 19, 2026
Merged

TecharyJames merged 3 commits into
BETAfrom
feat/full-detection-index

Conversation

@TecharyJames

@TecharyJames TecharyJames commented Sep 19, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #3 and #6, both merged. Fixes a blocking bug in the merged detection path and removes the coverage limit.

The index was never fetched on a detection-only endpoint

-NoRefresh was implemented as "never download". A machine that only runs the detection service installs nothing, so nothing else would ever fetch the index for it — those machines were left permanently without one and ProductCode detection could never work there.

Measured on a fresh endpoint before this change, with 7-Zip installed:

7zip.7zip        installed=False  via=None

-NoRefresh now means "do not re-download a copy we already have": absent means fetch, present means use what is there. Same result after:

7zip.7zip        installed=True   via=ProductCode   7-Zip 26.03 (x64)

Coverage was 12 packages

Detection resolved a package ID only if it appeared in Index/Catalog.json. Everything else fell back to name matching, which cannot resolve a winget package ID at all.

The detection index is now built from Microsoft's own published winget source, which already carries the ARP product codes and MSIX package family names that detection matches on, for every package in the repository:

Source https://cdn.winget.microsoft.com/cache/source2.msix
Download 3.5 MB, CDN, no credentials
api.github.com calls 0
Clone of winget-pkgs none

Tables used: packages, productcodes2, pfns2. Packages carrying neither a product code nor a package family name are excluded — they cannot be identified this way, so they are dead weight in a file every endpoint downloads.

Detection order

  1. Product codes and package family names from the full index
  2. Product code from the curated index or the local manifest cache
  3. Exact display name
  4. Substring
  5. MSIX name

Package family name matching uses -AllUsers when elevated, for the same reason as #4.

Scope note

Index/Catalog.json stays and still carries installer URLs and silent arguments, so a common install needs no API call either. It was never an allow-list — Install-TecharyApp resolves any winget package live from the manifest repository, and falls through to CustomApps.json for non-winget apps.

Verification

Client against an index in the exact shape the Action emits:

7zip.7zip                    installed=True   via=ProductCode        7-Zip 26.03 (x64)
Microsoft.WindowsTerminal    installed=True   via=PackageFamilyName  Microsoft.WindowsTerminal
Fake.NotInstalled            installed=False  via=None

covering an ARP product-code hit, an MSIX package-family hit, and a true negative.

Note on the first push

The initial commit used char(31) as a group_concat separator, with jq splitting on the matching unit-separator escape. That escape reached the workflow file as a raw 0x1F byte, which YAML does not permit, so the workflow failed to parse and produced no jobs at all. Any separator guaranteed safe inside a product code is a control character, so the query now emits one row per identifier and jq groups them - no separator, nothing to escape.

Lookup cost

The source carries every product code a package has ever shipped — Mozilla.Firefox has 5,205, one per locale and version. Probing those across three hives is 15,615 registry reads, measured at ~384 seconds, which would exceed a scan interval on its own.

Uninstall key names are enumerated once into a case-insensitive dictionary and each candidate is a hash lookup, so cost is ~230 registry reads regardless of how many codes a package carries. Ordinal-ignore-case is required rather than cosmetic: the source stores codes normalised to lower case (7-zip) while the real key is 7-Zip.

Timed against the real published index:

Mozilla.Firefox              0.47s  installed=True   via=ProductCode        Mozilla Firefox (x64 en-US)
7zip.7zip                    0.28s  installed=True   via=ProductCode        7-Zip 26.03 (x64)
Zoom.Zoom                    0.41s  installed=True   via=ProductCode        Zoom Workplace (64-bit)
Microsoft.WindowsTerminal    0.93s  installed=True   via=PackageFamilyName  Microsoft.WindowsTerminal

Zoom.Zoom returned False under the 12-package curated index and True here, which is the coverage change in one line.

Published index

The Action ran on this branch and published:

Packages 8,453
File size 6.07 MB
On the wire 1.51 MB (raw.githubusercontent serves gzip)
Refresh every 12h per endpoint

Packages whose manifests declare neither a product code nor a package family name are absent by design — Valve.Steam is one — and fall back to name matching as before.

Two problems with detection as merged.

The index was never fetched on an endpoint that only runs detection.
-NoRefresh was implemented as "never download", but a detection-only machine
installs nothing, so nothing else would ever fetch the index for it and
ProductCode detection could never work there. It now means "do not
re-download a copy we already have": absent means fetch, present means use
what is there.

Coverage was limited to the twelve packages in Index/Catalog.json. Detection
of anything else fell back to name matching, which cannot resolve a winget
package ID at all.

The detection index is now built from Microsoft's own published winget
source, which already carries the ARP product codes and MSIX package family
names that detection matches on, for every package in the repository. That
is one 3.5 MB CDN download in CI, with no api.github.com calls and no clone
of winget-pkgs. Packages carrying neither a product code nor a package
family name are omitted, because they cannot be identified this way.

Detection now tries, in order: product codes and package family names from
the full index, the product code from the curated index or the local
manifest cache, an exact display name, a substring, then an MSIX name.
Package family name matching uses -AllUsers when elevated, for the same
reason as the uninstall path.

The curated Index/Catalog.json stays, and still carries installer URLs and
silent arguments so a common install needs no API call either. It was never
an allow-list: Install-TecharyApp resolves any winget package live.
The separator in the SQL group_concat was char(31), and the matching jq
split used a \u001f escape. That escape reached the workflow file as a raw
unit separator byte, which YAML does not permit, so the workflow failed to
parse and the run produced no jobs at all.

Any separator safe inside a product code has to be a control character, so
the query now emits one row per identifier and jq groups them. No separator,
nothing to escape.
The winget source carries every product code a package has ever shipped:
Mozilla.Firefox alone has 5205, one per locale and version. Probing those
across three hives is 15,615 registry reads, measured at ~384 seconds, which
would exceed an N-central scan interval on its own.

The uninstall key names are now enumerated once into a case-insensitive
dictionary and each candidate is a hash lookup, so the cost is ~230 registry
reads regardless of how many codes a package carries. Firefox drops from
~384s to 0.47s.

Ordinal-ignore-case is required, not cosmetic: the source index stores codes
normalised to lower case ("7-zip") while the real key is "7-Zip".
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.

1 participant