Skip to content

feat(packaging): linux deb/rpm via nfpm#120

Merged
laplaque merged 11 commits into
mainfrom
claude/linux-packaging-nfpm-WB36O
May 17, 2026
Merged

feat(packaging): linux deb/rpm via nfpm#120
laplaque merged 11 commits into
mainfrom
claude/linux-packaging-nfpm-WB36O

Conversation

@iamclaude697
Copy link
Copy Markdown
Collaborator

@iamclaude697 iamclaude697 commented May 17, 2026

What

First phase of the UEM packaging workstream. Ships .deb and .rpm packages built reproducibly via nfpm so enterprise rollout tools (Intune, JAMF, SCCM, Ansible) can install the proxy non-interactively, register the CA in the OS trust store, and bring the service up under systemd with a single command — without baking config into the binary.

Changes

  • cmd/proxy/main.go — adds a one-shot --generate-ca / --ca-cert / --ca-key CLI dispatch that wraps the existing internal/mitm.GenerateCA. The post-install script calls this to bootstrap the CA non-interactively without needing openssl or interactive prompts.
  • packaging/linux/nfpm.yaml — single nfpm config producing .deb and .rpm for amd64 + arm64. Declares ca-certificates + systemd runtime deps so package managers pull what postinstall needs (fixed minimal-image gaps surfaced on Fedora / openSUSE in CI).
  • packaging/linux/ai-proxy.service — systemd unit with hardening (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, PrivateDevices, etc.). Reads either /etc/default/ai-proxy (Debian) or /etc/sysconfig/ai-proxy (RHEL) via EnvironmentFile=-.
  • packaging/linux/postinstall.sh — creates ai-proxy system user, generates CA if absent (idempotent), copies into the correct trust-store anchor directory per distro family (Debian / openSUSE / RHEL all differ), enables the unit, then on a real systemd-managed host enforces that systemctl start succeeds — on failure, rolls back the CA anchor and exits non-zero so the operator and any UEM tooling see the failure. Closes the PII-leak window from F3 in round 1.
  • packaging/linux/preremove.sh — stops the service, removes the CA from all three possible anchor directories.
  • packaging/linux/ai-proxy.env — env file template covering every env var consumed by cmd/proxy / internal/config.
  • packaging/linux/proxy-config.json.default — default config shipped as a conffile (preserved on upgrade).
  • Makefilemake package-linux (and per-arch variants).
  • .github/workflows/release-linux-packages.yml — CI pipeline: builds packages for both arches, runs install / verify / uninstall round-trip in privileged systemd-capable Docker containers across Ubuntu 22.04, 24.04, Debian 12, Alma 9, Fedora latest, openSUSE Leap 15. On release tags only: generates SHA256/512 sums and signs each artifact via Sigstore cosign keyless. nfpm pinned to v2.46.3. contents: write + id-token: write scoped to the release job only.
  • .github/scripts/test-install.sh — install round-trip verification mounted into each matrix container.
  • cmd/proxy/main_test.go — adds TestRunGenerateCA (table-driven contract test) + two helper-process tests (TestMain_HelperProcess_GenerateCA, TestMain_HelperProcess_GenerateCA_Fatal) that re-exec the binary via the GO_WANT_HELPER_PROCESS=1 pattern. Consolidates the existing per-callsite //nolint:gosec G204 directives into a single helperCmd(t, args...) helper carrying one suppression with a substantive reason (net gosec directive count in the file: 7 → 1).
  • docs/packaging/linux.md + docs/packaging/README.md — install / configure / verify / uninstall reference, plus a ⚠ Security posture — read before fleet deployment callout at the top spelling out the CA-trust-anchor implication, key location/mode, per-distro anchor paths, upgrade behaviour, and the F3 install-time guarantee.
  • README.md — adds a packaging row to the doc table.

Quality Gates

  • make check passed locally (lint, test, security, vulncheck — all four sub-gates exit 0). Last line of output:
    All checks passed.

    Sub-gate detail at d78f66c:

    • lintgolangci-lint v2.10.1 (the exact CI version, downloaded locally; the golangci-lint on $PATH is older than the project's Go 1.26 toolchain so the documented carve-out applies): 0 issues.
    • test — all 10 packages ok (no -race here; race is in the next gate).
    • security (gosec -exclude=G104,G304,G703,G706): Files : 31, Lines : 5933, Nosec : 5, Issues : 0.
    • vulncheck (govulncheck ./...): No vulnerabilities found.
  • go test -race ./... passed — all 10 packages ok with -race. Output:

    ok  	ai-anonymizing-proxy/cmd/proxy	6.606s	coverage: 100.0% of statements
    ok  	ai-anonymizing-proxy/internal/anonymizer	1.580s	coverage: 95.6% of statements
    ok  	ai-anonymizing-proxy/internal/anonymizer/packs	1.148s	coverage: 97.6% of statements
    ok  	ai-anonymizing-proxy/internal/config	1.020s	coverage: 100.0% of statements
    ok  	ai-anonymizing-proxy/internal/domainmatch	1.022s	coverage: 100.0% of statements
    ok  	ai-anonymizing-proxy/internal/logger	1.016s	coverage: 86.4% of statements
    ok  	ai-anonymizing-proxy/internal/management	1.045s	coverage: 89.8% of statements
    ok  	ai-anonymizing-proxy/internal/metrics	1.030s	coverage: 100.0% of statements
    ok  	ai-anonymizing-proxy/internal/mitm	22.442s	coverage: 85.7% of statements
    ok  	ai-anonymizing-proxy/internal/proxy	4.378s	coverage: 93.0% of statements
    

    TestTokenFormatNonRetriggering — this PR introduces no new PII types or pack patterns, so the "for every PII type" sub-clause is N/A. The existing test continues to pass under -race (part of internal/anonymizer's 202 PASS / 0 FAIL above).

  • Coverage minimums met:

    • internal/anonymizer95.6% (≥95% ✓)
    • internal/config100.0% (≥95% ✓)
    • internal/anonymizer/packs97.6% (≥95% ✓)
    • overall → 94.4% (≥85% ✓)
  • Delta coverage ≥90% on all changed/new files — see Delta Coverage Report below

  • §6 Test Inventory baseline-vs-head completed below

  • No hacks introduced — only //nolint:gosec G204 in cmd/proxy/main_test.go:38-41 (substantive reason: helper-process pattern; os.Args[0] is the test binary itself, not external input). Net gosec directives in that file went from 7 (5 pre-existing from chore(cmd/proxy): decompose main() for testability + close delta-coverage gap #121 + 2 added by this PR's initial draft) to 1 (consolidated into the helper). No t.Skip(), no // coverage-ignore, no test-satisfying hardcoded values.

  • All CI jobs green at the PR head SHA (16 SUCCESS + 1 correctly-SKIPPED Sign + publish, gated on refs/tags/v*).

Delta Coverage Report

Per .github/scripts/delta-coverage.sh — every function in changed/new .go source files (excluding _test.go, _generated.go, mock_*) must be ≥90%.

Command:

go test -race -coverprofile=coverage.out -covermode=atomic ./...
bash .github/scripts/delta-coverage.sh coverage.out 90.0 origin/main

Raw script output (verbatim from d78f66c vs origin/main):

=== Delta Coverage Check (threshold: 90.0%) ===

Changed source files:
  cmd/proxy/main.go


Checked 3 functions in 1 changed files.
SUCCESS: All functions in changed files meet 90.0% coverage threshold.

Per-function table — every function in every changed .go source file (test files filtered by the script; the only changed Go source file is cmd/proxy/main.go):

File Function Coverage % ≥90%
cmd/proxy/main.go main 100.0%
cmd/proxy/main.go runGenerateCA 100.0%
cmd/proxy/main.go printBanner 100.0%

main()'s 100% comes from the helper-process tests (TestMain_HelperProcess_*) introduced in #121 — they re-exec the test binary as the production binary, so main() itself runs under coverage instrumentation. This PR's only addition to main.go is the --generate-ca flag-dispatch + runGenerateCA helper; both are exercised by the new TestMain_HelperProcess_GenerateCA (success path) and TestMain_HelperProcess_GenerateCA_Fatal ([CA] log.Fatalf path), plus TestRunGenerateCA (in-process unit test for the helper's path-arg validation + file-on-disk + key-perm contract).

§6 Test Inventory — baseline vs head

Method per .github/PULL_REQUEST_TEMPLATE.md: checkout main, go test -race -count=1 -v <pkg>, count --- PASS / --- FAIL lines. Repeat on PR head SHA. Both executions performed; no diff-reasoning.

Package main (1ebc033) PASS / FAIL head (d78f66c) PASS / FAIL Delta
./cmd/proxy/ 14 / 0 17 / 0 +3 (TestRunGenerateCA + TestMain_HelperProcess_GenerateCA + TestMain_HelperProcess_GenerateCA_Fatal)
./internal/anonymizer/ 202 / 0 202 / 0 0
./internal/anonymizer/packs/ 71 / 0 71 / 0 0
./internal/config/ 34 / 0 34 / 0 0
./internal/logger/ 11 / 0 11 / 0 0
./internal/management/ 38 / 0 38 / 0 0
./internal/metrics/ 17 / 0 17 / 0 0
./internal/mitm/ 30 / 0 30 / 0 0
./internal/proxy/ 59 / 0 59 / 0 0

Zero failures on either side. Only positive delta is +3 in ./cmd/proxy/ covering the new --generate-ca paths.

Test Plan

New tests added in this PR (all in cmd/proxy/main_test.go):

  • TestRunGenerateCA — pins the contract cmd/proxy/runGenerateCA owns: rejects empty --ca-cert / --ca-key paths; surfaces filesystem errors when the cert directory is unwritable; on success writes both files with the key at mode 0600. The cryptographic shape of the generated CA is covered by internal/mitm tests; this test only owns the cmd-layer contract.
  • TestMain_HelperProcess_GenerateCA — exercises main()'s --generate-ca dispatch end-to-end via the helper-process pattern (GO_WANT_HELPER_PROCESS=1). Re-execs the test binary with --generate-ca --ca-cert <tmp> --ca-key <tmp>, asserts non-zero exit isn't returned and both files land at the expected paths.
  • TestMain_HelperProcess_GenerateCA_Fatal — exercises main()'s [CA] log.Fatalf branch. Re-execs with --generate-ca --ca-cert= --ca-key <tmp> (empty cert path → runGenerateCA returns error → log.Fatalf), asserts non-zero exit and [CA] in the captured stderr.

CI-side test plan (executed by .github/workflows/release-linux-packages.yml on this PR):

  • Build matrixmake package-linux-amd64 + make package-linux-arm64 produce 4 artifacts (.deb + .rpm × amd64 + arm64).
  • Install round-trip matrix.github/scripts/test-install.sh runs inside privileged containers across Ubuntu 22.04, Ubuntu 24.04, Debian 12, Alma 9, Fedora latest, openSUSE Leap 15. Verifies: binary at /usr/bin/ai-proxy, conffiles at /etc/ai-proxy/proxy-config.json and /etc/default/ai-proxy or /etc/sysconfig/ai-proxy, CA anchored in one of the three trust-store directories, systemd unit enabled. Then uninstalls and verifies the CA is removed from the trust store and the binary is gone.

Linked Issues

No linked GitHub issue. This is the first phase of the UEM packaging workstream tracked in the project wiki; subsequent phases (macOS PKG, Windows MSI, personal apt/yum repo, AUR) ship in their own PRs.


Out of scope (deferred to later UEM phases)

  • macOS .pkg + .mobileconfig — Phase 2
  • Windows MSI — Phase 3
  • Personal apt/yum repo on GitHub Pages — Phase 5c
  • AUR — Phase 5a
  • Per-package GPG signing — paired with the apt/yum repo in Phase 5c. Sigstore cosign on release artifacts is in scope and shipped.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 17, 2026

Benchmark results

Benchmark ns/op Budget Headroom Status
BenchmarkRegexPassEmail 1.8µs 2.5ms 99% ✅ PASS
BenchmarkRegexPassMultiple 2.7µs 2.5ms 99% ✅ PASS

Budget: regex/cache < 2.5ms (0.5% of 500ms baseline), streaming < 20ms (4% of 500ms baseline)

laplaque pushed a commit that referenced this pull request May 17, 2026
…rage gap (#121)

## Summary
Decomposes `cmd/proxy/main.go` `func main()` into separately-testable
startup
helpers and adds a helper-process `TestMain` that exercises `main()`
end-to-end.
Closes the structural delta-coverage gap surfaced by #118 — every
function in
`cmd/proxy/` now meets the 95% per-function gate, so future PRs touching
this
file (including #120's `--generate-ca` flag addition) land without false
coverage failures.

## Motivation
- #118 closed a silent-skip bug in `.github/scripts/delta-coverage.sh`.
Before
that fix, PRs touching `cmd/proxy/main.go` quietly bypassed the
per-function
  gate.
- #120 is the first PR to actually hit the now-loud gate. `main()` has
always
been 0% covered (network setup + signal handling), so any change to the
file
  fails 95%.
- Rather than per-PR workarounds, this PR fixes the root cause on
`main`:
refactor `main()` into testable helpers + helper-process tests that
exercise
  the real binary.

## What changed
- New `cmd/proxy/startup.go` — `proxyHTTPServer`, `startManagementAPI`,
`runManagementAPI`, `runHTTPServer`, `closeProxyServer` (extracted from
`main()`). `closeProxyServer` takes `io.Closer` so the error-log branch
is
  exercisable with a fake.
- New `cmd/proxy/shutdown.go` — `installShutdownHandler` (extracted from
  `main()`).
- New unit tests in `cmd/proxy/startup_test.go` and
`cmd/proxy/shutdown_test.go`
covering each helper at 100%. `TestCloseProxyServer_*` capture the
default
logger's output and assert the error message is/isn't emitted, not just
that
  both branches execute.
- `cmd/proxy/main_test.go` — `TestMain` dispatcher + four helper-process
tests
(lifecycle, zero-packs guard, proxy-port conflict, mgmt-port conflict)
using
the canonical Go helper-process pattern (`GO_WANT_HELPER_PROCESS=1` +
re-exec
of `os.Args[0]`). `TestPrintBanner_ZeroValueConfig_DoesNotPanic`
preserves
  the zero-value-config guarantee previously owned by `TestMain_Smoke`.
- `cmd/proxy/main.go` `func main()` reduced to orchestration; no
behaviour
  change.

## Per-function coverage (`go tool cover -func`)

```
cmd/proxy/main.go:39:     main                    100.0%
cmd/proxy/main.go:66:     printBanner             100.0%
cmd/proxy/shutdown.go:13: installShutdownHandler  100.0%
cmd/proxy/startup.go:17:  proxyHTTPServer         100.0%
cmd/proxy/startup.go:28:  startManagementAPI      100.0%
cmd/proxy/startup.go:37:  runManagementAPI        100.0%
cmd/proxy/startup.go:45:  runHTTPServer           100.0%
cmd/proxy/startup.go:54:  closeProxyServer        100.0%
total:                                            100.0%
```

Local `delta-coverage.sh coverage.out 95.0 origin/main` exits 0:

```
=== Delta Coverage Check (threshold: 95.0%) ===

Changed source files:
  cmd/proxy/main.go
  cmd/proxy/shutdown.go
  cmd/proxy/startup.go

Checked 8 functions in 3 changed files.
SUCCESS: All functions in changed files meet 95.0% coverage threshold.
```

## Quality gates
- [x] `go test -race ./...` green (all packages) — see
`TestTokenFormatNonRetriggering` evidence below
- [x] `bash .github/scripts/delta-coverage.sh coverage.out 95.0
origin/main`
      exits 0 with `SUCCESS`
- [x] Coverage minimums hold: `internal/anonymizer` 95.6%,
`internal/config`
      100%, `internal/anonymizer/packs` 97.6%; overall well above 85%
- [x] Lifecycle helper-process test stable across 5 consecutive local
runs
- [x] No `t.Skip()`, no `// coverage-ignore`, no hardcoded
test-satisfying
      values. Four `//nolint:gosec` and two `// #nosec` directives carry
      substantive reason comments per the existing repo convention.
- [ ] `make lint` / `make check` — `golangci-lint` in the local sandbox
is
built with go1.25 and refuses go1.26.3 modules (same failure on a clean
`main` checkout). CI runs all four sub-gates against a fresher
toolchain.

## Gate #2 — `TestTokenFormatNonRetriggering` for every PII type

Both variants live in `internal/anonymizer/anonymizer_test.go` and ran
green at
head `c69911f`:

```
=== RUN   TestTokenFormatNonRetriggering
[ANONYMIZER] loaded 41 patterns from 7 enabled packs: [SECRETS GLOBAL DE FR NL FINANCE_EU HEALTHCARE]
--- PASS: TestTokenFormatNonRetriggering (0.04s)
=== RUN   TestTokenFormatNonRetriggeringAllPacks
[ANONYMIZER] loaded 47 patterns from 8 enabled packs: [SECRETS GLOBAL DE FR US NL FINANCE_EU HEALTHCARE]
--- PASS: TestTokenFormatNonRetriggeringAllPacks (0.04s)
```

Each variant iterates over a static list of `PIIType` values and, for
each,
asserts the generated `[PII_*_<16hex>]` token does not match any loaded
pack
pattern. The PII types covered:

| Pack | PII types asserted by the test |
|---|---|
| Generic / built-in | `PIIEmail`, `PIIPhone`, `PIISSN`,
`PIICreditCard`, `PIIIPAddress`, `PIIAPIKey`, `PIIName`, `PIIAddress`,
`PIIMedical`, `PIISalary`, `PIICompany`, `PIIJobTitle` |
| DE | `PIISteuerID`, `PIISVNR`, `PIIKFZ` |
| SECRETS | `PIISSHKey`, `PIIJWT`, `PIIBearer`, `PIIDBConn`,
`PIIAWSKey`, `PIIGHToken` |
| FR | `PIINIR`, `PIISIRET`, `PIISIREN` |
| NL | `PIIBSN`, `PIIKVK` |
| FINANCE_EU | `PIIIBAN`, `PIISWIFTBIC`, `PIIVATID` |
| HEALTHCARE | `PIIMRN`, `PIIICD10`, `PIIInsuranceID` |

That's 32 PII types in the default-packs variant.
`TestTokenFormatNonRetriggeringAllPacks`
adds the US pack and drops the four AI-only types
(`PIIMedical/Salary/Company/JobTitle`),
covering 28 types under the full pattern set (`PIIKFZ`/`PIIDBConn` are
noted in
the test as known low-confidence retriggers against the broad US phone
pattern
and routed through the AI gate — documented in the test, not silenced).

The two variants together cross every PII type declared in
`internal/anonymizer/anonymizer.go`. None of the changed files in this
PR touch
the anonymizer, the pack files, or the PII type list, so no per-type
evidence
shifted between baseline and head — but the gate is exercised end-to-end
at
head as shown above.

## §6 Test Inventory — baseline vs head

Keyed by **pack** per `docs/test-plans/ai-proxy-test-method.md` §6.
Captured by running `go test -count=1 -json ./internal/anonymizer
./internal/anonymizer/packs`
on `origin/main` (`98511d7`) and on PR head (`c69911f`), then
attributing
each top-level Test event to its pack via the file list in §6.

| Pack | Files (per §6) | Baseline PASS | Baseline FAIL | Head PASS |
Head FAIL | Delta |
|---|---|---:|---:|---:|---:|---:|
| GLOBAL | `packs/global_test.go` | 6 | 0 | 6 | 0 | 0 |
| DE | `packs/de_test.go` | 5 | 0 | 5 | 0 | 0 |
| US | `packs/us_test.go` | 10 | 0 | 10 | 0 | 0 |
| FR | `packs/fr_test.go` | 9 | 0 | 9 | 0 | 0 |
| SECRETS | `packs/secrets_test.go` + `secrets_report_test.go` +
`secrets_priority_report_test.go` | 31 | 0 | 31 | 0 | 0 |
| NL | `packs/nl_test.go` + `nl_report_test.go` | 6 | 0 | 6 | 0 | 0 |
| FINANCE_EU | `packs/finance_eu_test.go` + `finance_eu_report_test.go`
| 7 | 0 | 7 | 0 | 0 |
| HEALTHCARE | `packs/healthcare_test.go` + `healthcare_report_test.go`
| 6 | 0 | 6 | 0 | 0 |
| Cross-pack | all `*_report_test.go` | 9 | 0 | 9 | 0 | 0 |

Counts are top-level `func Test*` events from the JSON stream, filtered
by the
test-name list extracted from each pack's `_test.go` file(s). The
`secrets_priority_report_test.go` file lives alongside
`secrets_report_test.go`,
was added after §6 was written, and is included under SECRETS (its
prefix
matches the SECRETS pack convention). The Cross-pack row overlaps with
the
pack rows by design (it sums all `*_report_test.go` content, which the
relevant per-pack rows already include).

**Supplementary Go-package view** — `go test -count=1 -json ./...` gives
861 → 871 PASS / 0 → 0 FAIL across all 10 Go packages; the `cmd/proxy`
row
went 4 → 14, every other row unchanged. The per-pack table above is the
gate-required view.

## §6 changed-files attribution
None of the changed files in this PR (`cmd/proxy/main.go`,
`cmd/proxy/startup.go`,
`cmd/proxy/shutdown.go`, plus the corresponding `_test.go` files) live
under
any pack's file list, so the identical per-pack counts are the expected
measured outcome, not a substitution for measurement.

## Test plan
- [x] `go test -race ./cmd/proxy/...` — all green including new
      helper-process tests
- [x] `go test -race -run '^TestTokenFormatNonRetriggering'
./internal/anonymizer/...` — both variants PASS at head
- [x] `go tool cover -func` — every function in `cmd/proxy/` at 100%
- [x] Local `delta-coverage.sh` simulation against `origin/main` — exits
0
- [x] Lifecycle test run 5x consecutively — 5/5 pass
- [x] CI green (Lint, Test, Security Scan, Benchmark, CodeQL × 2, Build)
at
      `c69911f`

## Out of scope
- Any changes under `packaging/linux/` — Fedora `ca-certificates` and
openSUSE
  trust-store path belong to #120 follow-up commits after rebase.
- Any changes to `internal/*`, `.github/workflows/`, or
  `.github/scripts/delta-coverage.sh`.
- Adding a second binary or the `--generate-ca` flag (#120's territory).

---------

Co-authored-by: Claude <noreply@anthropic.com>
claude added 6 commits May 17, 2026 16:05
Exposes the existing internal/mitm.GenerateCA via a one-shot flag so
package post-install scripts can generate the CA cert+key non-interactively
before the service starts.
nfpm.yaml produces .deb and .rpm for amd64 and arm64 from a single
config. systemd unit includes hardening (NoNewPrivileges, ProtectSystem,
PrivateTmp, etc.). Env file template covers every env var consumed by
cmd/proxy and internal/config. Default proxy-config.json is shipped
as a conffile so user edits survive upgrade.

Adds make package-linux / package-linux-{amd64,arm64} targets.
…re integration

postinstall.sh creates the ai-proxy system user, generates the CA if
absent (idempotent across upgrades), copies it into the OS trust store
(Debian-family /usr/local/share/ca-certificates, RHEL-family
/etc/pki/ca-trust/source/anchors), then enables + starts the service.

preremove.sh stops and disables the service and removes the CA from the
trust store. Files under /etc/ai-proxy/ are preserved on remove
(conffile semantics) and cleaned up on purge.
Builds .deb and .rpm packages for amd64 + arm64 on tag push (and on
PRs touching packaging paths). Runs an install/verify/uninstall
round-trip across Ubuntu 22.04/24.04, Debian 12, Alma 9, Fedora,
and openSUSE Leap 15 in privileged systemd-capable containers.

On tag pushes, signs each artifact (and SHA256/SHA512 sums) with
Sigstore cosign keyless and uploads everything to the GitHub release.
…ninstall

docs/packaging/linux.md covers per-family install commands, the env
file variables, upgrade semantics (conffile preservation), uninstall,
and cosign signature verification. docs/packaging/README.md is an
index pointing at linux.md and noting macos/windows arrive in later
UEM phases. README.md doc table gets a packaging row.
…path

The Fedora and openSUSE install round-trips failed because:
1. ca-certificates is not in those minimal base images, so postinstall's
   update-ca-trust / update-ca-certificates command was absent and the
   trust-store install silently no-op'd.
2. openSUSE ships update-ca-certificates but reads anchors from
   /etc/pki/trust/anchors (Debian uses /usr/local/share/ca-certificates).
   The original postinstall blindly used the Debian path, so the CA never
   reached the trust store.

Fixes:
- Declare ca-certificates and systemd as package deps so dnf/apt/zypper
  pull them automatically.
- Detect the right anchor directory in postinstall (Debian / openSUSE /
  RHEL) before copying the CA.
- preremove now removes from all three possible anchor paths.
- test-install.sh accepts the openSUSE path as a valid trust-store
  location during verification.
@iamclaude697 iamclaude697 force-pushed the claude/linux-packaging-nfpm-WB36O branch from e33170f to c933bb4 Compare May 17, 2026 16:06
Copy link
Copy Markdown
Owner

@laplaque laplaque left a comment

Choose a reason for hiding this comment

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

PR Review: feat(packaging): linux deb/rpm via nfpm

Evidence block

  • Head SHA: c933bb4cf73284f4121b2b9954afe8a1aa49d665
  • CI: 16/17 SUCCESS + 1 correctly-SKIPPED (Sign + publish, gated on refs/tags/v*)
  • Gate-defining document: CLAUDE.md (read at head SHA)
  • Sibling docs read: CONTRIBUTING.md, .github/PULL_REQUEST_TEMPLATE.md, .github/scripts/delta-coverage.sh

Pipeline status

PASS

Test coverage verdict

UNVERIFIABLE — author offered no per-package coverage numbers (gate 3), no delta-coverage report (gate 4), no §6 baseline-vs-head inventory (gate 5).

Quality gates audit

Each gate parsed into clauses; per-clause score based strictly on evidence offered in the PR body / commits / comments. CI green is external evidence the suite ran; it does not substitute for author-side documentation of the gate (per CLAUDE.md and the .github/PULL_REQUEST_TEMPLATE.md "Empty checkboxes block merge; vague 'passes' assertions without evidence count as empty").

# Clause (verbatim from CLAUDE.md / PR template) Score
1a make check passes locally — aggregates lint, test, security, vulncheck; all four sub-gates exit 0 FAIL — author explicitly documents the lint sub-gate was not run locally ("golangci-lint was not run locally … CI runs golangci-lint v2.10.1 and will exercise the new code there"); no claim either way for the security (gosec) or vulncheck sub-gates
1b Last-line-of-output paste (template) NEED-EVIDENCE
2a go test -race ./... passes NEED-EVIDENCE — -race flag not confirmed (PR body says go test ./...)
2b …including TestTokenFormatNonRetriggering for every PII type NEED-EVIDENCE — sub-clause silent (likely N/A since no new PII types, but the gate must still be addressed)
3 Coverage minimums: 95% on internal/anonymizer, internal/config, internal/anonymizer/packs; 85% overall NEED-EVIDENCE — no per-package coverage numbers offered
4 Delta coverage ≥90% on all changed/new files (per .github/scripts/delta-coverage.sh) NEED-EVIDENCE — template ## Delta Coverage Report section (raw script output paste + per-function table) is entirely absent from the PR body
5 Test-plan inventory baseline-vs-head; "Diff reasoning … does NOT satisfy this gate" NEED-EVIDENCE — template ## §6 Test Inventory — baseline vs head section is entirely absent from the PR body
6a No //nolint without substantive reason PASS — three new directives at cmd/proxy/main_test.go:148, :320, :344 all carry substantive reasons
6b No new gosec exclusions (PR template clause) NEED-EVIDENCE / PARTIAL — three new //nolint:gosec inline directives added (same lines); clause not addressed by the author
6c No t.Skip() without linked issue / no // coverage-ignore / no test-satisfying hardcoded values PASS
7 All CI jobs green at PR head SHA PASS

Template-structure note (umbrella)

The PR body has none of the required template sections (## What, ## Changes, ## Quality Gates checklist, ## Delta Coverage Report, ## §6 Test Inventory — baseline vs head, ## Test Plan, ## Linked Issues). Custom sections (## What ships, ## Verified locally, ## Existing Issues, ## Out of scope) replaced the template wholesale. This is the root cause of the NEED-EVIDENCE clauses above.

Severity grading note

All five inline code findings (F2 – F6) touch security paths and are graded HIGH per reviewer policy: anything security-touching is HIGH, no exceptions. The release pipeline supply chain (F2), CA-trust-anchor with silent service-start failure (F3), security-linter inline suppressions (F4), over-broad CI permissions (F5), and user-facing CA-trust-anchor disclosure for UEM deployments (F6) all qualify.

Path to Approval (Action Plan)

  1. Re-state the PR body using the required template sections, with evidence per the gate: Quality Gates checklist (each checkbox with concrete evidence); Delta Coverage Report (raw .github/scripts/delta-coverage.sh output paste + per-function table); §6 Test Inventory baseline-vs-head (per-package PASS/FAIL on main AND head); Test Plan (named test functions and what each one pins); Linked Issues.
  2. Resolve the lint sub-gate of make check locally — or, if the toolchain mismatch is structural, declare the deviation and the compensating evidence explicitly under gate #1, including the currently-silent security (gosec) and vulncheck sub-gates.
  3. Address the five inline security findings (F2 supply-chain pin; F3 silent service-start suppression with CA already trust-anchored; F4 gosec inline exclusions; F5 over-broad workflow permissions; F6 UEM consent-grade CA disclosure).
  4. Re-run review at the new head SHA.

Verdict

REQUEST_CHANGES

Comment thread .github/workflows/release-linux-packages.yml Outdated
Comment thread packaging/linux/postinstall.sh Outdated
Comment thread cmd/proxy/main_test.go Outdated
Comment thread .github/workflows/release-linux-packages.yml
Comment thread docs/packaging/linux.md Outdated
@laplaque
Copy link
Copy Markdown
Owner

Reviewer self-correction — gate #1a should be LOW, not FAIL.

Per the standing review policy ([[claude/feedback/ai-proxy-quality-gate-severity-bump]] established during the PR #121 review on 2026-05-17), the exception to severity-bumping on gate #1 (make check passes locally) applies when:

  1. The author discloses the local failure in the PR body with a concrete reason, AND
  2. CI is green at the head SHA.

Both conditions hold here: the PR body's "Existing Issues" section discloses the golangci-lint-vs-Go-1.26-toolchain mismatch concretely, and CI at the head SHA is green (16/17 SUCCESS + 1 correctly-SKIPPED). Per the exception, gate #1a should have been rated LOW, not FAIL. The other clauses under gate #1 (security/vulncheck sub-gates, last-line paste) stay NEED-EVIDENCE.

Verdict remains REQUEST_CHANGES — driven by:

  • Gates 4 (Delta Coverage Report absent) and 5 (§6 Test Inventory absent) — both author-evidence gates the template requires
  • F2 / F3 / F4 / F5 / F6 — five HIGH security findings independent of gate scoring

This is a reviewer-side correction only; no action needed from the author beyond what the original review already requested.

claude added 5 commits May 17, 2026 17:42
Addresses two review threads on PR #120:

- F2: replace 'go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest'
  with the same package pinned to v2.46.3. nfpm builds the release
  artifacts that cosign then signs; the build tool itself is in the
  supply-chain trust path, so its version must be reviewable. Bumps
  now require an explicit PR.

- F5: move 'contents: write' and 'id-token: write' from workflow-level
  permissions into the release job only. The build and test-install
  jobs (the latter running --privileged docker containers) inherit
  'contents: read' instead. A compromise in build or test-install can
  no longer mint OIDC tokens or push to the release.
… window)

Addresses F3 on PR #120. The previous '|| true' on systemctl start
swallowed every failure mode (port collision, bad config, AppArmor/
SELinux denial, missing runtime dep) while still leaving the CA
installed into the OS trust store from earlier in postinstall. On a
real host this would mean a package-manager SUCCESS with a CA that
browsers and CLIs trust but no proxy running to intercept — exactly
the failure mode CLAUDE.md Invariant #1 ("No PII leaves the process")
exists to prevent. HTTPS LLM-API requests would succeed, trusted, and
carry PII off the host unanonymized.

Two changes:
- Detect the chroot/container build context by the presence of
  /run/systemd/system (canonical 'systemd is PID 1 right now' marker)
  and only enforce service-start in real systemd-managed hosts. The
  container build case still just enables the unit.
- On a real host, if 'systemctl start' fails, the trust-store anchor
  is removed and postinstall exits non-zero so the package manager
  surfaces the failure to the operator and any UEM rollout tooling.
…suppressions

Addresses F4 on PR #120. The file previously carried five //nolint:gosec
directives at exec.CommandContext call sites and one at os.ReadFile —
each substantive but each also a per-line suppression of the security
linter.

- Centralises subprocess construction in helperCmd(t, args...). The
  gosec G204 suppression now lives in one place (the helper) next to
  the explanation of why the pattern is safe (the target is the test
  binary itself, not external input), and every helper-process test
  becomes shorter.
- Removes the os.ReadFile + pem.Decode + x509.ParseCertificate block
  from TestRunGenerateCA. The cryptographic shape of the generated CA
  is covered by tests in internal/mitm; cmd/proxy/runGenerateCA's
  contract is path-arg validation + file-on-disk presence + key
  permission, and that's what the test now asserts. Drops the
  remaining gosec G304 suppression at the same time.

Net: 7 //nolint:gosec directives in main_test.go reduced to 1.
… rollouts

Addresses F6 on PR #120. The CA install was bullet-noted alongside
'creates the ai-proxy user', which under-disclaims for the audience
that reads this doc — fleet admins approving a UEM rollout (Intune,
JAMF, SCCM, Ansible). docs/tls-mitm.md covers the architectural MITM
story; this doc is the operational one that gates rollout approval.

Adds a 'Security posture' callout at the top of docs/packaging/linux.md
spelling out:
- After install, the host trusts an ai-proxy-controlled CA capable of
  MITM-ing any HTTPS connection from it.
- The private key at /etc/ai-proxy/ca-key.pem must be protected with
  host-SSH-key rigour; anyone who can read it can mint trusted certs.
- Per-distro trust-store anchor paths so security teams can audit.
- Uninstall removes the anchor; failed service-start aborts install
  with the anchor rolled back (paired with the F3 postinstall fix).
CI's misspell linter (US-English mode) flagged 'Centralising' in the
helperCmd comment added in 9394eb7.
Copy link
Copy Markdown
Owner

@laplaque laplaque left a comment

Choose a reason for hiding this comment

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

PR Review (round 2): feat(packaging): linux deb/rpm via nfpm

Evidence block

  • Head SHA: d78f66c5624e69db638ddd448b8f22295a6ec4e9
  • Prior review: 4305992554 (CHANGES_REQUESTED at c933bb4c…) plus #issuecomment-4471866528 self-correction on gate #1a
  • New commits since prior review: 5 (4c0cf6d, 32c5359, 9394eb7, b18b6b6, d78f66c)
  • CI: 16/17 SUCCESS + 1 correctly-SKIPPED (Sign + publish) at new head SHA
  • Re-walked Step 3.7 (gate enumeration) in full at new head — every clause re-scored against the new state, not only previously-failing clauses

Inline findings — resolved at this head SHA

# Resolution Verified at d78f66c
F2 nfpm pinned @v2.46.3 (commit 4c0cf6d); paired with F5 narrowing so build-job compromise cannot mint OIDC tokens .github/workflows/release-linux-packages.yml:44
F3 Distinguishes chroot/container (no /run/systemd/system → skip start, OK) from real systemd host (start failure → log + roll back CA from trust store + exit 1); package install now fails loudly on real-environment failure (commit 32c5359) packaging/linux/postinstall.sh:56,91-96
F4 Refactored to drop the suppressions (commit 9394eb7 + spelling fix d78f66c). Consolidated all helper-process exec.CommandContext(os.Args[0], …) calls into a single helperCmd(t, args...) helper carrying one //nolint:gosec G204 with substantive reason; os.ReadFile G304 removed entirely by tightening the test's contract. Net gosec directives in file: 7 → 1 cmd/proxy/main_test.go:38-41
F5 File-level permissions: contents: read; per-job blocks explicit on build + test-install (read only); contents: write + id-token: write confined to release job (commit 4c0cf6d) .github/workflows/release-linux-packages.yml:15-16,22-23,60-61,97-99
F6 Added ## ⚠ Security posture — read before fleet deployment as the first content after the page title; verbatim consent-grade wording; enumerates key location/mode, per-distro anchor paths, upgrade/uninstall behaviour, and the F3 install-time guarantee (commit b18b6b6) docs/packaging/linux.md:5-15

All five HIGH-security inline findings from the prior review are addressed at this head SHA. Thank you for the thorough fixes — F3 in particular (the rollback-on-real-failure path) closes the PII-leak window cleanly.

Quality gates audit at d78f66c

# Clause (verbatim) Score
1a make check passes locally — aggregates lint, test, security, vulncheck; all four sub-gates exit 0 LOW — the carved exception applies (author disclosed toolchain mismatch + CI green at head; per reviewer policy this stays at original severity)
1b Last line of make check output pasted (PR template) HIGH — NEED-EVIDENCE
2a go test -race ./... passes (with -race) HIGH — NEED-EVIDENCE
2b …including TestTokenFormatNonRetriggering for every PII type HIGH — NEED-EVIDENCE
3 Coverage minimums: 95% on internal/anonymizer, internal/config, internal/anonymizer/packs; 85% overall HIGH — NEED-EVIDENCE
4 Delta coverage ≥90% on all changed/new files (per .github/scripts/delta-coverage.sh) HIGH — NEED-EVIDENCE
5 Test-plan inventory baseline-vs-head; "Diff reasoning … does NOT satisfy this gate" HIGH — NEED-EVIDENCE
6a No //nolint without substantive reason PASS — single remaining directive in helperCmd carries substantive reason
6b No new gosec exclusions (PR template clause) PASS (with note) — net gosec directive count in cmd/proxy/main_test.go strictly decreased (7 → 1); the remaining directive is a consolidation of pre-existing helper-process suppressions, not a fresh exclusion
6c No t.Skip() without linked issue / no // coverage-ignore / no test-satisfying hardcoded values PASS
7 All CI jobs green at PR head SHA PASS (16 SUCCESS + 1 SKIPPED)

Severity bump on the NEED-EVIDENCE gates is per the standing reviewer policy for ai-proxy CLAUDE.md gates: findings against documented quality gates default one severity level higher than they would on a generic project, because the gates are a documented merge contract.

Template-structure observation (still applies)

The PR body still has none of the required template sections (## What, ## Changes, ## Quality Gates checklist, ## Delta Coverage Report, ## §6 Test Inventory — baseline vs head, ## Test Plan, ## Linked Issues). All five NEED-EVIDENCE clauses above sit downstream of this umbrella issue. The template preamble says: "Every section below is required. Empty checkboxes block merge; vague 'passes' assertions without evidence count as empty."

Path to Approval — concrete actions (the prior path was too narrow; each NEED-EVIDENCE clause is a separate action item)

Each item below is something to add to the PR body. None requires a new commit — they are documentation deliverables, addressable via a PR-body edit. The five inline findings from round 1 ARE addressed at this head SHA; this list is exclusively the gate-evidence gaps that the prior review's audit table flagged but the prior Path to Approval did not enumerate concretely.

  1. Gate 1b — last-line-of-make check paste. Add a Quality Gates section to the PR body using the template's checkbox layout. The gate-1 checkbox slot reads Last line of output: <paste>. Paste the last line of your local make check (with the golangci-lint carve-out documented separately, as already in the PR body).
  2. Gate 2a — -race flag confirmation. Under the gate-2 checkbox, state explicitly that go test -race ./... passes (with the -race flag). Current PR body says go test ./....
  3. Gate 2b — TestTokenFormatNonRetriggering sub-clause. Under the gate-2 checkbox, address the "for every PII type" sub-clause. If this PR introduces no new PII types, state that explicitly so the sub-clause is addressed rather than silent.
  4. Gate 3 — per-package coverage minimums. Under the gate-3 checkbox, confirm the four threshold packages are at their minimums at this head SHA: internal/anonymizer ≥95%, internal/config ≥95%, internal/anonymizer/packs ≥95%, overall ≥85%. The PR doesn't touch the three 95%-gated packages, so baseline should hold — but the gate requires explicit confirmation per package.
  5. Gate 4 — ## Delta Coverage Report section. Add the template-specified section. Per the template at .github/PULL_REQUEST_TEMPLATE.md and the script at .github/scripts/delta-coverage.sh, this section must contain:
    • Raw script output paste verbatim — including the Changed source files: list, every per-FAIL line if any, and the final SUCCESS: / ERROR: line, inside the code-block placeholder the template provides.
    • Per-function table — one row per function in every changed .go source file (cmd/proxy/main.go and cmd/proxy/main_test.go are excluded as _test.go per the script's filter; cmd/proxy/main.go is in scope) with columns File | Function | Coverage % | ≥90%.
    • CI green on the Test job is the script running; it is not author-side documentation of the result, which the gate text and template both require.
  6. Gate 5 — ## §6 Test Inventory — baseline vs head section. Add the template-specified section. The CLAUDE.md gate text states: "Document per-package PASS/FAIL counts in the PR body under a ## §6 Test Inventory — baseline vs head section. Diff reasoning … does NOT satisfy this gate." The template renders the per-package table empty (./cmd/proxy/, ./internal/anonymizer/, ./internal/anonymizer/packs/, ./internal/config/, ./internal/logger/, ./internal/management/, ./internal/metrics/, ./internal/mitm/, ./internal/proxy/) — fill each cell with main and head PASS/FAIL counts and a delta. The gate's referenced doc is docs/test-plans/ai-proxy-test-method.md §6.
  7. Template structure umbrella. Restructure the PR body to follow .github/PULL_REQUEST_TEMPLATE.md. The current sections (## What ships, ## Verified locally, ## Existing Issues, ## Out of scope) can be merged into the template's ## What + ## Changes + ## Quality Gates + ## Test Plan + ## Linked Issues shape; items 1–6 above are the contents of the missing template sections.

The gate text and the PR template both already specify the evidence shape and the commands that produce the evidence — items 1–6 cite those locations rather than prescribing new commands, per reviewer-scope discipline.

Verdict

REQUEST_CHANGES — driven by gates 1b, 2a, 2b, 3, 4, 5, and the template-structure umbrella. All inline security findings from the prior review are resolved at this head SHA.

Copy link
Copy Markdown
Owner

@laplaque laplaque left a comment

Choose a reason for hiding this comment

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

All round-2 Path-to-Approval bullets addressed via PR-body restructure (no new commits required for the documentation gates).

Inline findings F2–F6 remain closed at this head SHA. Gate-evidence summary:

  • Gate 1: make check last-line + per-sub-gate detail (lint with documented golangci-lint v2.10.1 workaround, test 10/10, security 0 issues, vulncheck no vulnerabilities)
  • Gate 2: -race per-package output (10/10 ok); TestTokenFormatNonRetriggering sub-clause N/A statement (no new PII types)
  • Gate 3: per-package coverage all above minimums (internal/anonymizer 95.6%, internal/config 100.0%, internal/anonymizer/packs 97.6%, overall 94.4%)
  • Gate 4: Delta Coverage Report — raw .github/scripts/delta-coverage.sh output + per-function table (cmd/proxy/main.go 3/3 functions at 100%)
  • Gate 5: §6 Test Inventory — per-package PASS/FAIL counts on main (1ebc033) vs head (d78f66c); +3 in ./cmd/proxy/ matching the 3 new test names; zero failures either side
  • Gate 6: net gosec directive count strictly decreased (7 → 1, consolidated into helperCmd)
  • Gate 7: CI green at head SHA (16 SUCCESS + 1 correctly-SKIPPED)
  • Template: all 7 required sections present and ordered

Dismissing both prior CHANGES_REQUESTED reviews (4305992554 at c933bb4c + 4306082414 at d78f66c5) per SKILL.md Step 9 GitHub re-review caveat so branch-protection sees a clean APPROVE.

@laplaque laplaque dismissed stale reviews from themself May 17, 2026 18:27

Superseded by APPROVE review # at head d78f66c5 — all inline findings (F2–F6) and all gate-evidence (Quality Gates checklist, Delta Coverage Report, §6 Test Inventory, template structure) addressed.

@laplaque laplaque merged commit 6436ce0 into main May 17, 2026
17 checks passed
@laplaque laplaque deleted the claude/linux-packaging-nfpm-WB36O branch May 17, 2026 18:30
laplaque added a commit that referenced this pull request May 17, 2026
Phase 2 of the UEM packaging workstream — macOS PKG installer (signed +
notarized + stapled on tag builds) and a JAMF-deployable `.mobileconfig`
profile.

## What ships

- `packaging/macos/pkg/` — `build.sh`, `distribution.xml`,
`scripts/postinstall`, `scripts/preuninstall`, `notarize.sh`, the
in-package LaunchDaemon plist, the conffile defaults. `build.sh` and
`mobileconfig/build.sh` both honor `SKIP_SIGN=1` for the PR-event
dry-run path.
- LaunchDaemon at
`/Library/LaunchDaemons/com.ai-anonymizing-proxy.plist`,
`RunAtLoad=true`, `KeepAlive={SuccessfulExit=false}`
- `_aiproxy` system user created in postinstall (UID auto-allocated
220–400, hidden, no shell)
- CA injection into `/Library/Keychains/System.keychain`. **Postinstall
rollback verifies actual keychain state** and exits with distinct codes
— `1` = "service down, CA cleaned", `2` = "service down, CA STILL
TRUSTED, manual remediation required" (H2).
- `packaging/macos/mobileconfig/ai-proxy.mobileconfig.tmpl` + `build.sh`
— CA trust + global HTTP proxy payloads, with **ExceptionsList** for
`127.0.0.1`, `localhost`, `*.local`, `169.254/16` (M3).
- `make package-macos`, `make package-macos-pkg`, `make
package-macos-mobileconfig` (refuse to run off Darwin)
- `.github/workflows/release-macos-pkg.yml`:
- **PR runs** do a `Validate build mechanism (PR dry-run, no signing)`
step (N1).
- **Tag runs** execute the full sign + notarize + staple + upload path.
- P12 cleanup via `trap EXIT` (H5). Explicit artifact upload list (H6).
`concurrency:` block (L2). YAML comment above `Notarize PKG` references
issue #125 (H3).
- `docs/packaging/macos.md` — install / configure / verify / upgrade /
uninstall, blast-radius callout (H7), per-route rotation (M4),
troubleshooting (L3), pre-tag verification ritual (H3).
- Issue #125 — pre-tag MDM-host HTTPS-interception verification ritual.

## Quality Gates

- [x] `make check` passed locally on `8e5c975`. lint: `0 issues.` /
test: all 10 `ok` / security: `Issues : 0` / vulncheck: `No
vulnerabilities found.`
- [x] `go test -race ./...` passed on `8e5c975` — every package `ok`, no
`--- FAIL`.
- [x] Coverage minimums met (table below). 95%+ on anonymizer / config /
packs; 94.4% total.
- [x] Delta coverage ≥90% — N/A by gate (zero `.go` files in PR diff;
script skip is documented behavior).
- [x] [§6 Test Inventory
baseline-vs-head](#6-test-inventory--baseline-vs-head) completed.
- [x] No hacks — diff grep at head returns empty for
`TODO|FIXME|HACK|XXX|//nolint|t\.Skip|coverage-ignore`.
- [x] All CI jobs green at the PR head SHA `8e5c975`.

### Coverage (per Go package)

| Package | Coverage | Gate |
|---|---|---|
| `./cmd/proxy/` | 100.0% | — |
| `./internal/anonymizer/` | 95.6% | ≥95% ✅ |
| `./internal/anonymizer/packs/` | 97.6% | ≥95% ✅ |
| `./internal/config/` | 100.0% | ≥95% ✅ |
| `./internal/domainmatch/` | 100.0% | — |
| `./internal/logger/` | 86.4% | — |
| `./internal/management/` | 89.8% | — |
| `./internal/metrics/` | 100.0% | — |
| `./internal/mitm/` | 85.7% | — |
| `./internal/proxy/` | 93.0% | — |
| **total** | **94.4%** | ≥85% ✅ |

## Delta Coverage Report

```bash
go test -race -coverprofile=coverage.out -covermode=atomic ./...
bash .github/scripts/delta-coverage.sh coverage.out 90.0 origin/main
```

Output:
```text
No changed Go source files — delta coverage check skipped.
```

PR diff contains zero `.go` files (`git diff --name-only
--diff-filter=ACMR origin/main...HEAD -- '*.go'` is empty). The script's
documented behavior on empty Go diffs is to skip with exit 0.

## §6 Test Inventory — baseline vs head

**Method per `docs/test-plans/ai-proxy-test-method.md`:**
1. Spec source: §6 table, organized **per PII pack**, mapping each pack
to its unit test file and (where present) report test file.
2. For each row, extracted top-level test names from the named file(s)
with `grep -oE '^func (Test[A-Za-z0-9_]+)'`, passed them to `go test
-race -count=1 -v -run "^(N1|N2|...)$" <pkg>`.
3. Counted `--- PASS` and `--- FAIL` lines **top-level + subtests** per
CLAUDE.md gate spec — `grep -cE '^[[:space:]]*--- PASS'` (the
leading-whitespace match captures indented subtest results, which the
prior round's `^--- PASS` missed; **all previously-posted numbers were
undercounted**).
4. Two independent worktrees (`git worktree add /tmp/main-tree 6436ce0`
and the PR head), separate `go test` invocations against separate trees.
Not derived from `git diff`.

| §6 row | File(s) | main (`6436ce0`) PASS / FAIL | head (`8e5c975`)
PASS / FAIL | Δ |
|---|---|---|---|---|
| GLOBAL | `packs/global_test.go` | 22 / 0 | 22 / 0 | 0 |
| DE | `packs/de_test.go` | 17 / 0 | 17 / 0 | 0 |
| US | `packs/us_test.go` | 38 / 0 | 38 / 0 | 0 |
| FR | `packs/fr_test.go` | 41 / 0 | 41 / 0 | 0 |
| SECRETS (unit) | `packs/secrets_test.go` | 28 / 0 | 28 / 0 | 0 |
| SECRETS (report) | `anonymizer/secrets_report_test.go` | 44 / 0 | 44 /
0 | 0 |
| NL (unit) | `packs/nl_test.go` | 12 / 0 | 12 / 0 | 0 |
| NL (report) | `anonymizer/nl_report_test.go` | 6 / 0 | 6 / 0 | 0 |
| FINANCE_EU (unit) | `packs/finance_eu_test.go` | 15 / 0 | 15 / 0 | 0 |
| FINANCE_EU (report) | `anonymizer/finance_eu_report_test.go` | 12 / 0
| 12 / 0 | 0 |
| HEALTHCARE (unit) | `packs/healthcare_test.go` | 4 / 0 | 4 / 0 | 0 |
| HEALTHCARE (report) | `anonymizer/healthcare_report_test.go` | 15 / 0
| 15 / 0 | 0 |
| Cross-pack (all report tests) |
`anonymizer/{secrets,nl,finance_eu,healthcare,secrets_priority}_report_test.go`
| 90 / 0 | 90 / 0 | 0 |

`diff /tmp/inv6-main.txt /tmp/inv6-head.txt` returns empty. Zero deltas
on every row.

### §5 Known-issues pinning tests

The method's §5 names specific tests as pins for known issues. Each was
executed independently by name on both worktrees:

| Pinning test | Issue | main (`6436ce0`) | head (`8e5c975`) | Δ |
|---|---|---|---|---|
| `TestDESteuerIDPattern` | #67 | 1 / 0 | 1 / 0 | 0 |
| `TestDESVNRPattern` | #67 | 1 / 0 | 1 / 0 | 0 |
| `TestUSAddressPattern` (German* subtests) | #68 | 7 / 0 | 7 / 0 | 0 |
| `TestSIREN_SSN_CrossPattern` | #69 | 4 / 0 | 4 / 0 | 0 |
| `TestFRSIRENPattern` | #69 | 1 / 0 | 1 / 0 | 0 |
| **`TestSecretsPriorityOverGLOBAL`** | **#70** | **12 / 0** | **12 /
0** | **0** |
| `TestNLKvKPattern` | (design) | 1 / 0 | 1 / 0 | 0 |
| `TestFINANCEEUSWIFTBICPattern` | (design) | 1 / 0 | 1 / 0 | 0 |
| `TestHEALTHCAREICD10Pattern` | (design) | 1 / 0 | 1 / 0 | 0 |
| `TestHEALTHCAREMRNPattern` | (design) | 1 / 0 | 1 / 0 | 0 |

Issue #70 / `TestSecretsPriorityOverGLOBAL` directly pins **CLAUDE.md
Invariant #3** ("SECRETS must precede GLOBAL"). Verified PASS at head by
name.

### §2.3 Report-test config compliance

Every report test under `internal/anonymizer/*_report_test.go` declares
the canonical configuration the method requires:

```bash
grep -n "EnabledPacks" internal/anonymizer/*_report_test.go
```

Returns `EnabledPacks: []string{"SECRETS", "GLOBAL", "US", "DE", "FR",
"NL", "FINANCE_EU", "HEALTHCARE"}` in every report test, with `UseAI:
false` and `PackDecayRate: 0.0` adjacent. SECRETS precedes GLOBAL —
Invariant #3 satisfied at the config level, in addition to the
named-test pin above.

### Supplemental: per-Go-package rollup

| Package | main (`6436ce0`) | head (`8e5c975`) | Δ |
|---|---|---|---|
| `./cmd/proxy/` | 17 / 0 | 17 / 0 | 0 |
| `./internal/anonymizer/` | 202 / 0 | 202 / 0 | 0 |
| `./internal/anonymizer/packs/` | 71 / 0 | 71 / 0 | 0 |
| `./internal/config/` | 34 / 0 | 34 / 0 | 0 |
| `./internal/domainmatch/` | 5 / 0 | 5 / 0 | 0 |
| `./internal/logger/` | 11 / 0 | 11 / 0 | 0 |
| `./internal/management/` | 38 / 0 | 38 / 0 | 0 |
| `./internal/metrics/` | 17 / 0 | 17 / 0 | 0 |
| `./internal/mitm/` | 30 / 0 | 30 / 0 | 0 |
| `./internal/proxy/` | 59 / 0 | 59 / 0 | 0 |

Note: these per-package counts use `go test -v -count=1 ./<pkg>`
(whole-package) with subtest-aware grep. They are a rollup, not the gate
artifact — the §6 per-pack table above is the artifact the spec demands.

## Review-feedback resolution

| ID | Round | Fix |
|---|---|---|
| H1 | R1 | PR body disclosure + checklist filled. |
| H2 | R1 (`00d341a`) | postinstall rollback verifies cert is actually
absent; distinct exit code 2 when CA STILL TRUSTED. |
| H3 | R1 → R2 reformulated | Trackable artifact triple: issue #125 +
workflow YAML comment + docs § Pre-tag verification ritual. |
| H4 | R1 (`79c409b`) | Secret-verify + signing/notarize gated on
`IS_RELEASE`. |
| H5 | R1 (`79c409b`) | P12 cleanup via `trap EXIT`. |
| H6 | R1 (`79c409b`) | Artifact upload explicit filenames. |
| H7 | R1 (`c219de1`) | Blast-radius callout in docs. |
| M1 | R1 (`b9ce7c6`) | macOS env file ports match plist. |
| M2 | R1 (`b9ce7c6`) | `distribution.xml` `pkg-ref version` rendered
from `$VERSION`. |
| M3 | R1 (`b9ce7c6`) | `.mobileconfig` ExceptionsList. |
| M4 | R1 (`c219de1`) | Rotation per route. |
| L1 | R1 (`00d341a`) | bootstrap stderr captured. |
| L2 | R1 (`79c409b`) | concurrency block. |
| L3 | R1 (`c219de1`) | Troubleshooting section. |
| N1 | R2 (`274e319`) | PR-event dry-run exercises build mechanism. |
| Gate 5 §6 inventory | R3 | Per-pack execution; previously was
per-Go-package. |
| Gate 5 subtest counting + §5 pins + §2.3 config | R4 | Subtest-aware
grep (`^[[:space:]]*--- PASS`); §5 named-test pin table; §2.3 config
compliance verified. |

## Verified

- `bash -n` clean on every shell script
- `xmllint --noout` clean on `distribution.xml`, the LaunchDaemon plist,
and the `.mobileconfig.tmpl`
- YAML parse clean on the workflow
- JSON parse clean on `proxy-config.json.default`
- `make check` clean on `8e5c975`
- `go test -race ./...` clean on `8e5c975`
- §6 inventory: two independent worktree executions, per-pack,
subtest-aware, zero deltas
- §5 known-issues pinning tests verified by name on both worktrees, zero
deltas
- §2.3 report-test config compliance grep'd from source
- `make -n package-macos-pkg` gates on Darwin
- PR-event macOS CI: dry-run path exercises payload staging + pkgbuild +
productbuild + mobileconfig render unsigned

**Pre-tag manual gate (issue #125):** `.mobileconfig` HTTPS-interception
verification on MDM-enrolled host — required before pushing any `v*`
tag.

## Out of scope

- Homebrew tap (Phase 5a)
- Windows MSI (Phase 3)
- Sparkle / auto-update integration
- JAMF policy templates wrapping the .mobileconfig (left to admins)

## Related

- Previous: Phase 1 Linux DEB/RPM (PR #120)
- Next: Phase 3 Windows MSI + ADMX
- Pre-tag gate: #125
laplaque added a commit that referenced this pull request May 17, 2026
## What

Phase 3 of the UEM packaging workstream — a per-machine `x64` Windows
MSI built via WiX Toolset (v4 schema, v7 CLI), plus an ADMX/ADML Group
Policy template for managed-fleet proxy configuration. The proxy binary
is now Windows-service-aware: SCM-launched processes register a service
handler within milliseconds, translate `Stop`/`Shutdown` into a bounded
graceful HTTP shutdown, and exit cleanly. Per-host CA generation mirrors
the Linux postinstall pattern (idempotent on upgrade); the CA is
imported into `Cert:\LocalMachine\Root` on install and removed on
uninstall via a Go helper rather than inline PowerShell.

## Changes

- **`internal/envfile/`** — new `KEY=VALUE` parser + `--env-file` CLI
flag so the MSI service can hand the binary a single config-file path
(no Windows-native `EnvironmentFile=` equivalent exists).
- **`internal/config/policy.go` + `policy_windows.go`** — Windows-only
read of `HKLM\SOFTWARE\Policies\laplaque\AiProxy`; `Address` overrides
`BindAddress` and `Port` overrides `ProxyPort`. Layering: defaults →
file → env → policy. **`applyPolicy` writes only to `BindAddress` /
`ProxyPort` — never to `EnabledPacks`, so the §1.2 pipeline-ordering
invariant is preserved structurally, not just empirically.** A
`registryGetter` interface plus a `fakeRegistry` in `policy_test.go`
make the apply logic testable on any platform.
- **`cmd/proxy/service_lifecycle.go` + `service_windows.go`** —
platform-neutral `runServiceLifecycle` implements the SCM contract
(`StartPending` → `Running` → bounded `srv.Shutdown` → exit) and is
exercised by `service_lifecycle_test.go` (graceful stop, interrogate,
shutdown-timeout, bind-failure). The Windows-only file is now a thin
SCM-channel bridge.
- **`cmd/proxy/caremove*.go`** — new `--remove-ca-from-store` flag.
Parses the PEM, computes the SHA-1 thumbprint, and shells out to
`certutil -delstore` from Go. Idempotent on "cannot find" so re-running
the uninstaller is safe.
- **`packaging/windows/wix/`** — `Product.wxs`, `Service.wxs`,
`CATrust.wxs`, `build.ps1`. Per-machine MSI, `MajorUpgrade`,
restart-on-failure (5s delay, 1-day reset), CA generated via
`ai-proxy.exe --generate-ca` (idempotent), imported with `certutil`,
removed on uninstall via `ai-proxy.exe --remove-ca-from-store`.
`<util:PermissionEx>` on the `CADir` component restricts
`C:\ProgramData\AiProxy` to `LocalSystem` + `Administrators`.
- **`packaging/windows/admx/`** — `ai-proxy.admx` +
`en-US/ai-proxy.adml` exposing Enable, Address, Port to GPO admins.
- **`make package-windows`** +
`.github/workflows/release-windows-msi.yml` — `windows-latest` build,
`wix eula accept wix7`, AzureSignTool with HSM-backed EV cert, `signtool
verify`, round-trip `msiexec /qn /i ... /x ...` test.
- **`docs/packaging/windows.md`** — install/configure/uninstall, GPO
deployment, Intune commands, signature verification.

## Quality Gates

- [x] `make check` passed locally (lint, test, security, vulncheck — all
four sub-gates exit 0). Last lines of output:
  ```
  Running gosec security scanner...
  ...
    Issues : 0
  Running govulncheck...
  Verifying packages...
  Verifying modules...
  No vulnerabilities found.
  All checks passed.
  ```
(Locally `gosec` runs the same `-exclude=G104,G304,G703,G706` set as the
Makefile; the new SHA-1 thumbprint code uses gosec `#nosec G401`/`G505`
annotations with reason comments — no new global exclusions.)
- [x] `go test -race ./...` passed
- [x] `TestTokenFormatNonRetriggering` and
`TestTokenFormatNonRetriggeringAllPacks` both pass at head
- [x] Coverage minimums met: `internal/config` 100.0%,
`internal/anonymizer` 95.6%, `internal/anonymizer/packs` 97.6%; overall
well above 85%
- [x] Delta coverage ≥95% on all changed/new files
- [x] [§6 Test Inventory
baseline-vs-head](#6-test-inventory--baseline-vs-head) completed below —
full regression gate per `docs/test-plans/ai-proxy-test-method.md`
§§1–6: §5 anchors, §2.3 standard config, §4 invariants, §6 inventory all
verified
- [x] No hacks introduced (no `//nolint` without a substantive reason;
no `t.Skip()`; no `// coverage-ignore`; no hardcoded test-satisfying
values; no new gosec exclusions in the Makefile — only file-level
`#nosec` directives with reason comments)
- [ ] All CI jobs green at the PR head SHA — pipeline is being re-walked
at the latest head after the review fixes; will tick once CI confirms

## Delta Coverage Report

**Command:**
```bash
go test -race -coverprofile=coverage.out -covermode=atomic ./...
bash .github/scripts/delta-coverage.sh coverage.out 95.0 origin/main
```

**Raw script output:**
```text
=== Delta Coverage Check (threshold: 95.0%) ===

Changed source files:
  cmd/proxy/caremove.go
  cmd/proxy/caremove_other.go
  cmd/proxy/caremove_windows.go
  cmd/proxy/main.go
  cmd/proxy/service_lifecycle.go
  cmd/proxy/service_other.go
  cmd/proxy/service_windows.go
  internal/config/config.go
  internal/config/policy.go
  internal/config/policy_other.go
  internal/config/policy_windows.go
  internal/envfile/envfile.go


Checked 22 functions in 12 changed files.
SUCCESS: All functions in changed files meet 95.0% coverage threshold.
```

**Per-function table** (every changed file, ≥95% on the Linux runner):

| File | Function | Coverage % | ≥95% |
|---|---|---|---|
| cmd/proxy/caremove.go | `certThumbprint` | 100.0% | ✅ |
| cmd/proxy/caremove_other.go | `removeCAFromStore` | 100.0% | ✅ |
| cmd/proxy/caremove_windows.go | `removeCAFromStore` | (Windows-only) |
n/a on Linux runner |
| cmd/proxy/main.go | `main` | 96.6% | ✅ |
| cmd/proxy/main.go | `runServerOrService` | 100.0% | ✅ |
| cmd/proxy/main.go | `runGenerateCA` | 100.0% | ✅ |
| cmd/proxy/main.go | `printBanner` | 100.0% | ✅ |
| cmd/proxy/service_lifecycle.go | `runServiceLifecycle` | 95.7% | ✅ |
| cmd/proxy/service_other.go | `runAsServiceIfNeeded` | 100.0% | ✅ |
| cmd/proxy/service_windows.go | `runAsServiceIfNeeded` / `Execute` |
(Windows-only) | n/a on Linux runner |
| internal/config/config.go | `Load` | 100.0% | ✅ |
| internal/config/policy.go | `applyPolicy` | 100.0% | ✅ |
| internal/config/policy_other.go | `loadPolicy` | 0-of-0 statements |
n/a (empty stub) |
| internal/config/policy_windows.go | `loadPolicy` / `winRegistryGetter`
| (Windows-only) | n/a on Linux runner |
| internal/envfile/envfile.go | `Apply` | 96.0% | ✅ |
| internal/envfile/envfile.go | `unquote` | 100.0% | ✅ |

The two Windows-only paths (`policy_windows.go::loadPolicy`,
`service_windows.go::*`) are exercised by the round-trip `msiexec /qn
/i` test on the `windows-latest` CI runner; on the Linux runner their
build-tag stubs are tested instead, and the cross-platform `applyPolicy`
+ `runServiceLifecycle` paths cover the actual logic (see H4a/H4b in the
review fixes).

## §6 Test Inventory — baseline vs head

The full §§1–6 of `docs/test-plans/ai-proxy-test-method.md` is the
regression gate. Every part is exercised on both `main` (6436ce0) and
head (ace8487).

### §1.2 / §4 — Pipeline-ordering invariant

The methodology requires patterns to be evaluated in `EnabledPacks`
order (`SECRETS → GLOBAL → US → DE → FR → NL → FINANCE_EU →
HEALTHCARE`), so that specific secret patterns aren't consumed by
GLOBAL's broad `api_key` regex (issue #70).

This PR introduces a new config layer (Windows Group Policy).
`applyPolicy` writes only to `cfg.BindAddress` and `cfg.ProxyPort`:

```go
// internal/config/policy.go
if addr, ok := g.GetString("Address"); ok && addr != "" {
    cfg.BindAddress = addr
}
if port, ok := g.GetUint64("Port"); ok && port > 0 && port <= 65535 {
    cfg.ProxyPort = int(port)
}
```

`cfg.EnabledPacks` is never written by the new code path, so the
pipeline-ordering invariant is preserved structurally.
`TestSecretsPriorityOverGLOBAL` (12 subtests) is the empirical witness —
see §5 table below.

### §2.3 — Standard configuration intact

The report tests on both sides still pin:

```go
Options{
    UseAI:        false,
    PackDecayRate: 0.0,
    EnabledPacks:  []string{"SECRETS", "GLOBAL", "US", "DE", "FR", "NL", "FINANCE_EU", "HEALTHCARE"},
}
```

Confirmed by `grep -rE "UseAI|PackDecayRate|EnabledPacks"
internal/anonymizer/*_report_test.go` returning identical entries on
both `main` and head across all five report-test files.

### §5 — Named regression-anchor tests (both sides identical)

Each anchor named in §5 ran on `main` and on head with `go test -race
-count=1 -v -run "^<Test>$" <pkg>`:

| Test (§5 reference) | Package | main PASS / FAIL | head PASS / FAIL |
Delta |
|---|---|---|---|---|
| `TestDESteuerIDPattern` | `internal/anonymizer/packs/` | 1 / 0 | 1 / 0
| 0 |
| `TestDESVNRPattern` | `internal/anonymizer/packs/` | 1 / 0 | 1 / 0 | 0
|
| `TestUSAddressPattern` (subtests: `German_ist`, `German_ist_2`,
`German_Kunst`, `German_Durst`, `German_erst`, `German_Bist`) |
`internal/anonymizer/packs/` | 10 / 0 | 10 / 0 | 0 |
| `TestSIREN_SSN_CrossPattern` (issue #69) |
`internal/anonymizer/packs/` | 4 / 0 | 4 / 0 | 0 |
| `TestFRSIRENPattern` | `internal/anonymizer/packs/` | 1 / 0 | 1 / 0 |
0 |
| `TestSecretsPriorityOverGLOBAL` (issue #70 — empirical witness for
§1.2 ordering) | `internal/anonymizer/` | 12 / 0 | 12 / 0 | 0 |
| `TestNLKvKPattern` | `internal/anonymizer/packs/` | 1 / 0 | 1 / 0 | 0
|
| `TestFINANCEEUSWIFTBICPattern` | `internal/anonymizer/packs/` | 1 / 0
| 1 / 0 | 0 |
| `TestHEALTHCAREICD10Pattern` | `internal/anonymizer/packs/` | 1 / 0 |
1 / 0 | 0 |
| `TestHEALTHCAREMRNPattern` | `internal/anonymizer/packs/` | 1 / 0 | 1
/ 0 | 0 |
| **§5 total** |  | **33 / 0** | **33 / 0** | **0** |

### §6 — Inventory (per-file PASS/FAIL on both sides)

| Pack | File | main (6436ce0) PASS / FAIL | head (ace8487) PASS / FAIL
| Delta |
|---|---|---|---|---|
| GLOBAL (unit) | `internal/anonymizer/packs/global_test.go` | 6 / 0 | 6
/ 0 | 0 |
| DE (unit) | `internal/anonymizer/packs/de_test.go` | 5 / 0 | 5 / 0 | 0
|
| US (unit) | `internal/anonymizer/packs/us_test.go` | 10 / 0 | 10 / 0 |
0 |
| FR (unit) | `internal/anonymizer/packs/fr_test.go` | 9 / 0 | 9 / 0 | 0
|
| SECRETS (unit) | `internal/anonymizer/packs/secrets_test.go` | 28 / 0
| 28 / 0 | 0 |
| SECRETS (report) | `internal/anonymizer/secrets_report_test.go` | 1 /
0 | 1 / 0 | 0 |
| NL (unit) | `internal/anonymizer/packs/nl_test.go` | 4 / 0 | 4 / 0 | 0
|
| NL (report) | `internal/anonymizer/nl_report_test.go` | 2 / 0 | 2 / 0
| 0 |
| FINANCE_EU (unit) | `internal/anonymizer/packs/finance_eu_test.go` | 5
/ 0 | 5 / 0 | 0 |
| FINANCE_EU (report) | `internal/anonymizer/finance_eu_report_test.go`
| 2 / 0 | 2 / 0 | 0 |
| HEALTHCARE (unit) | `internal/anonymizer/packs/healthcare_test.go` | 4
/ 0 | 4 / 0 | 0 |
| HEALTHCARE (report) | `internal/anonymizer/healthcare_report_test.go`
| 2 / 0 | 2 / 0 | 0 |
| **Cross-pack (report)** |
`internal/anonymizer/secrets_priority_report_test.go` | 2 / 0 | 2 / 0 |
0 |
| **§6 total** |  | **80 / 0** | **80 / 0** | **0** |

Zero failures on either side, zero net delta, every §5 anchor passes
identically, §2.3 standard config remains the pinned report-test
configuration, and the §1.2 / §4 pipeline-ordering invariant is
preserved structurally (the new policy layer never writes
`EnabledPacks`).

New tests added by this PR live outside the §6 anonymizer inventory (in
`./cmd/proxy/`, `./internal/config/`, `./internal/envfile/`) — see the
[Test Plan](#test-plan) section.

## Test Plan

New tests added by this PR (all run as part of `go test -race ./...`):

- `cmd/proxy/main_test.go`:
- `TestMain_HelperProcess_EnvFile_Loaded` / `_Fatal` — pins both
branches of the new `--env-file` dispatch path in `main()`.
- `TestMain_HelperProcess_RemoveCAFromStore_Fatal` — pins the
`--remove-ca-from-store` dispatch path (success branch is exercised on
Windows via the MSI round-trip test).
- `cmd/proxy/run_test.go`:
- `TestRunServerOrService_ServiceModeReturnsImmediately` — swaps the
`serviceDispatcher` package var with a fake that returns true; covers
the SCM early-return branch on any platform.
- `cmd/proxy/service_lifecycle_test.go`:
- `TestRunServiceLifecycle_StopGracefully` — pins the `StartPending` →
`Running` → `StopPending` transitions on a clean stop.
- `TestRunServiceLifecycle_InterrogateReEmits` — pins the SCM
Interrogate handling.
- `TestRunServiceLifecycle_ShutdownTimeoutLogged` — pins the
`srv.Shutdown` error path with a 50 ms test-only deadline and a held
request.
- `TestRunServiceLifecycle_BindFailureReturnsNonZero` — pins exit code 1
on listener collision.
- `cmd/proxy/caremove_test.go`:
- `TestCertThumbprint_MatchesSHA1OfDER` — pins the thumbprint format
Windows certutil expects.
- `TestCertThumbprint_MissingFile` / `_NotPEM` / `_InvalidCertBytes` —
pin every failure mode of the parser.
- `internal/envfile/envfile_test.go`:
- Table-driven tests for `Apply` covering simple pairs, comments, blank
lines, quoted values, embedded `=`, empty values, missing-`=` errors,
empty-key errors, missing-file errors, and oversized-line scanner
errors.
- `internal/config/policy_test.go`:
- `TestApplyPolicy_NilGetter_NoOp` / `OverridesAddressAndPort` /
`EmptyAddressIgnored` / `OutOfRangePortIgnored` /
`AbsentKeysLeaveDefaults` — pin every branch of the cross-platform GPO
apply logic via a `fakeRegistry` stub.

End-to-end coverage on the actual Windows service + MSI + cert-store
paths is delivered by the `.github/workflows/release-windows-msi.yml`
round-trip step on `windows-latest`: `msiexec /qn /i` → assert
`Get-Service ai-proxy` is `Running` and the CA is in
`Cert:\LocalMachine\Root` → `msiexec /qn /x` → assert both are gone.

## Linked Issues

- Implements UEM packaging Phase 3 (Windows MSI + ADMX).
- Previous: Phase 1 Linux DEB/RPM (#120).
- Next: Phase 4 unified silent installer.

## Tooling note (M3 from review)

The WiX XML schema this PR targets is the v4 namespace
(`http://wixtoolset.org/schemas/v4/wxs`), which is the stable schema
produced by every WiX release since v4 — the CLI invoking it is `wix
--version 7.0.0` (the current `dotnet tool install --global wix`
default). v7 introduced the OSMF EULA gate, which `build.ps1` accepts
via `wix eula accept wix7` before any other command. The CI workflow
pins the `actions/setup-dotnet` SHA but intentionally tracks the latest
`wix` CLI so security/bugfix releases are picked up.
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.

3 participants