A user installed CodePulse v2.3.111 the morning after we shipped it. The installer wizard ran. The progress bar advanced through three of four phases. The fourth phase — install hooks, register them with Claude Code, write the marker file — produced the bright-red banner familiar to anyone who has shipped a Windows installer: Step 3/4: Installation FAILED. There were no log entries. The diagnostic file was empty. The PowerShell window had opened and closed in milliseconds without writing a single byte. We pushed v2.3.112 within two hours. The v2.3.112 hotfix introduced a new parse bomb of the same root-cause class within the line that fixed the first one.
This is the post-mortem on two consecutive production releases broken by the same family of PowerShell 5.1 quirks, and the 3-second AST CI gate that closed the door on the entire class.
The release that exit-coded 1 with no output
The v2.3.111 release shipped TAB-587, an installer-performance overhaul that collapsed three to four spawned PowerShell processes per install phase into a single dot-sourced call. The dot-source pattern needed shared state, so the installer scripts gained $Script: scoped variables to communicate between functions in the same process. Among the new code on line 147 of install.ps1:
return @{
ExitCode = 1;
Output = '';
Status = "Failed to load $Script: $($_.Exception.Message)"
}
The intent was clear. Interpolate the value of $Script into the error string, follow with a colon and a space, then interpolate the exception message. Every pwsh author has written that line a thousand times. PowerShell 5.1 disagrees about what it means.
PS 5.1 parses $Script: inside a double-quoted string as a scope modifier prefix. After the colon it expects a variable name. It got a space. The parser returned InvalidVariableReference: '$' was not followed by a valid variable name character. This was not a runtime error. It fired at script load time, before any of our code executed. NSIS spawned powershell.exe install.ps1, the parser threw, exit code 1 was returned, and our entire install pipeline reported failure with zero output and zero log lines.
The user-visible symptom was indistinguishable from a missing file, a permissions issue, or any of a dozen install-time failure modes. The diagnostic surface had collapsed entirely because the parse error fired before the line that opens the log file.
The fix took ten minutes:
"Failed to load $($Script): $($_.Exception.Message)"
Wrapping $Script in a $(…) subexpression breaks PowerShell's lookahead for a scope-modifier construction. The : becomes literal text. We tagged v2.3.112, pushed it, and watched the release pipeline land the new installer to R2.
The fix that introduced a worse bomb
Within the same hotfix commit we touched build-support-email.ps1 to fix an unrelated email-client encoding issue. While doing so we cleaned up the script's diagnostic strings — and added a UTF-8 em-dash inside one of the double-quoted error messages. The change was visually clean, syntactically valid in any modern editor, and it broke every installer running on a non-CP1252 locale.
The mechanic is worth slowing down for, because the same trap exists in every BOM-less .ps1 file authored on a UTF-8 system and consumed on Windows.
PowerShell 5.1 reads .ps1 files using the system's default ANSI codepage when no byte-order mark is present. On English Windows, that codepage is CP1252 (Western European). The em-dash character — has a UTF-8 encoding of three bytes: E2 80 94. PS 5.1 reads those bytes one at a time and looks each one up in CP1252. The first two bytes map to glyphs that PS treats as opaque text inside a string. The third byte, 0x94, maps to the curly closing double-quote " — which the PowerShell parser treats as identical to the straight " for string termination purposes.
The result: PS 5.1 reads our em-dash as [two random characters][closing double-quote]. The string terminates mid-expression. The remainder of the line becomes a syntax error. The parser throws.
At build-support-email.ps1:23 char:48
+ ... message = "Could not open mailto link — please copy this URL"
~
The string is missing the terminator: ".
The second hotfix is sometimes worse than the first because the engineering team's confidence in their own work is highest right after they have just ship-tested a fix. We had pushed v2.3.112 and seen R2 light up green. The v2.3.112 build itself was fine — the new parse bomb only fires when v2.3.112 runs on an end-user machine, and only when the user's locale defaults to a CP1252 codepage. We had no smoke that fast.
The third release, v2.3.113, scrubbed every non-ASCII character out of every installer .ps1 file in the repo and added a CI gate we should have had two releases earlier.
Why the gate was a 3-second job that prevented a 10-minute build
The fix that makes both classes of bug structurally impossible is small. PowerShell ships its own AST parser as a public .NET API. It runs in milliseconds. We added a step at the start of release.yml that walks every .ps1 file in install/, parses it, and fails the workflow if any file has parse errors or contains non-ASCII bytes without a UTF-8 BOM:
$failed = 0
Get-ChildItem -Path install -Recurse -Filter '*.ps1' | ForEach-Object {
# AST parse check
$errors = $null; $tokens = $null
[System.Management.Automation.Language.Parser]::ParseFile(
$_.FullName, [ref]$tokens, [ref]$errors) | Out-Null
if ($errors.Count -gt 0) {
Write-Host "::error::PARSE ERROR in $($_.Name)"
foreach ($e in $errors) {
Write-Host " L$($e.Extent.StartLineNumber): $($e.Message)"
}
$failed++
}
# BOM / ASCII check — prevents locale-dependent parse failures
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
$hasBom = ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF
-and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)
$hasNonAscii = $false
foreach ($b in $bytes) {
if ($b -gt 127) { $hasNonAscii = $true; break }
}
if ($hasNonAscii -and -not $hasBom) {
Write-Host "::error::$($_.Name) has multibyte bytes but no UTF-8 BOM"
$failed++
}
}
if ($failed -gt 0) { throw "Installer .ps1 pre-flight failed: $failed file(s)" }
The gate runs in about three seconds across our 14 installer scripts. It runs before the 10-minute Tauri+NSIS build step. A parse error or a multibyte-without-BOM file fails the workflow in under five seconds with a precise file-and-line error. Both v2.3.111 and the v2.3.112 hotfix would have failed this check before any artifact was built.
The cost-benefit math here is not subtle. Three seconds per CI run, a handful of CI minutes per month, against two production hotfix releases that broke 100% of new installs and required emergency follow-up work in under twelve hours. We should have had this gate before the first parse bomb shipped. We will have it before the next one.
The encoding policy we adopted
After v2.3.113 we wrote a one-paragraph rule into the install scripts' header comments and into our internal contributor guide:
Every
.ps1file in this repository must be either pure 7-bit ASCII or saved as UTF-8 with a BOM. PowerShell 5.1 reads BOM-less multibyte files using the system's default ANSI codepage, which differs by locale. A UTF-8 character that the author saw correctly on macOS or Linux can be parsed as garbage — including string-terminating quotes — on a Japanese, Turkish, or even default English Windows install. There is no acceptable middle ground. Pick one or the other.
The CI gate enforces this rule mechanically. Authors cannot accidentally re-introduce a multibyte-without-BOM file because the gate fails the workflow before the build runs. The same rule applies to any interpreted-language file in the repo whose runtime is locale-sensitive; PS 5.1 is the canonical example, but the same trap exists in older Python-on-Windows codepaths and in some shell-script scenarios.
For new PowerShell-author contributors, the rule has a corollary that is worth saying out loud. Do not use Unicode typography in PowerShell strings unless you have already written a UTF-8 BOM at the top of the file. Em-dashes, en-dashes, smart quotes, and right-arrow characters are all encoded as multibyte sequences whose lower bytes happen to map to control or termination characters in CP1252. Substituting --, ->, ', and " is not a stylistic regression — it is a correctness requirement when the file lacks a BOM.
The diagnostic-surface problem
A parse error fires before any line of script executes. That is a structural fact about how PowerShell, like every interpreted language, loads files. It also means every diagnostic facility you build inside the script is unavailable when a parse error occurs. Your log file is not opened. Your error handler is not registered. Your trap blocks are not active. Your custom exit-code translation table is not read. The runtime returns an exit code, the host process gets it, and the user sees whatever the host chose to surface.
In our case, NSIS surfaced "Step 3/4: Installation FAILED" — a string defined in the NSIS script, with no programmatic way to retrieve the underlying PowerShell parser error. The user had a failed install with no actionable next step. The support cost of a parse-error failure is exponentially higher than the support cost of a runtime error, because the user cannot screenshot anything useful and we cannot ask them to send the log they do not have.
This is the deeper reason the AST gate matters. It is not just that we caught a bug. It is that we caught a class of bug that, when it ships, presents to the user as an opaque crash with no debugging surface. We did not gain "an extra check" — we removed an entire failure category from the production surface. That is a different shape of investment, and it is the shape we are choosing more often as the release pipeline ages.
Why neither tsc nor the test suite caught this
A reasonable reaction to "we shipped a syntax error" is "where were the tests?" We have a thorough TypeScript test suite for the service code. We had tsc --noEmit running in CI. We had an established rule, written in our internal contributor docs, that every commit should pass the type-checker before review. None of it applied here.
Both bombs lived in PowerShell files. PowerShell is not TypeScript. tsc does not load .ps1 files. The test suite tests the service binary, which never invokes the installer scripts directly. Our integration tests for the installer ran a successful install on a Windows runner, but the runner's locale was English-CP1252 — exactly the locale where the em-dash bomb does not fire on a fresh test, because the v2.3.112 installer's CP1252 misread of the em-dash happens at parse time and on the runner's locale the misread is also CP1252-style and somehow consistent with itself in ways that I have not fully reverse-engineered. The end result was that the installer integration test passed on the runner and failed on the user.
The general lesson here is one we have written about in another context: the test confirms the runtime that the test runs in, not necessarily the runtime that the user runs in. Our installer integration tests now run on three locales (en-US, ja-JP, tr-TR) precisely to surface CP1252-vs-Shift-JIS-vs-CP1254 divergence. Adding the locales took 20 lines of GitHub Actions YAML. It is the kind of test infrastructure that we should have invested in before TAB-588, not after.
What we would tell another team adopting PowerShell for an installer
Three rules emerged from the saga, and we are putting them in our contributor guide for any future PowerShell-touching work.
Treat parse-time correctness as the highest-priority CI check. Parse errors disable the diagnostic surface that all other CI checks depend on. They should be detected first, fastest, and most cheaply. The AST parser ships in System.Management.Automation.Language.Parser and runs in milliseconds. There is no excuse for shipping a .ps1 file that has not been parsed in CI.
Adopt and enforce a single encoding rule for every interpreted file. Pick pure ASCII or UTF-8-with-BOM. Codify the choice in CI. Do not trust authors to remember it under deadline pressure. The CI gate is the structural protection; author memory is the soft target.
Reproduce the user's locale, not just the developer's. Locale-sensitive bugs in interpreted languages are not theoretical. They are real, they are expensive when they ship, and they are cheap to catch in CI if you actually run your tests under multiple locales.
The same logic applies to other interpreted languages in your release artifacts. Python-on-Windows had similar cp1252 traps for years. Bash scripts loaded by non-bash shells can hit the same family of bugs. YAML files parsed by libraries with different schema interpretations can fail silently. The PowerShell-on-Windows case is the most acute because the parser is the most permissive about accepting bad files in some configurations and the most strict about rejecting them in others. But it is not the only case.
If your installer or CLI ships interpreted-language files that the user's machine loads at runtime, and if your CI is not currently running the target runtime's parser against those files before the build, you are one BOM-less em-dash away from your own version of this story. The cost to add the gate is three seconds. The cost to ship without it is two emergency hotfix releases in 48 hours.
We have run twelve releases since v2.3.113. Zero parse bombs. The structural fix held.
Ready to ship Windows installers without the silent-failure tax? Download CodePulse and let your phone catch the install issues your CI cannot. The free tier includes the zero-config installer and the approval pipeline — upgrade to Premium to unlock AI commit review, the Genius Supervisor, and voice input.