Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.0.30] - 2026-05-30

**Fixes the `Dismount-WindowsImage -Save` lock that broke ISO creation — at its real root cause.** v1.0.29 (now retracted) misdiagnosed the WIM-commit failure as transient host interference and bolted on a retry; the lock is actually an **in-process .NET registry-provider handle** held by the build's own PowerShell process, which a retry can never clear. Offline-hive value enumeration now uses `reg.exe` exclusively (never the `HKLM:\z*` provider), so no hive handle is held at dismount time — the reg.exe-only pattern of upstream `tiny11builder` and Microsoft's offline-servicing docs. Pester 521/0 (plus the admin-gated Synthetic harness).

### Fixed

- **The Standard build no longer fails at `Dismount-WindowsImage -Save` with "The process cannot access the file because it is being used by another process."** Root cause: `Invoke-RegistryPatternZeroAction` read the loaded offline `NTUSER` hive through the .NET PowerShell registry **provider** (`Test-Path` / `Get-Item` on `HKLM:\z…`). The provider caches an in-process `RegistryKey` handle to the hive's backing file *inside the mount*; the kernel keeps a hive loaded while any key handle is open, and `reg unload` has no force flag — so the handle survived into the dismount and locked `install.wim`. v1.0.29's retry could not help: the handle is held by the same long-lived process for the whole retry window, and GC (the only thing that would release it) is non-deterministic and never fires during the backoff. The latent bug has existed since **v1.0.3** — the provider read predates the v1.0.28 idiom tweak and was byte-identical in the v1.0.27 "revert to v1.0.6" — surfacing intermittently with GC timing and deterministically once a build actually reached the commit step. This corrects the v1.0.26–v1.0.29 "a transient WIM dismount/save file-lock is the leading suspect" framing: it was never environmental.
- **Stale-hive recovery (`Clear-Tiny11StaleHives`) no longer opens a provider handle either.** It detected stranded `HKLM\z*` hives with `Test-Path HKLM:\z…`, which — on the exact stranded-hive path it exists to recover — opened a fresh in-process handle. It now probes via `reg.exe` (`Test-Tiny11HiveLoaded`).

### Changed

- **All offline-hive registry access in the build process is now `reg.exe`-only.** New `Tiny11.Hives` helpers: `Get-Tiny11RegValueNames` (value-name enumeration via `reg query`, replacing `Get-Item … | Select-Object -ExpandProperty Property`), `Test-Tiny11HiveLoaded` (load check via `reg query`), and `Invoke-Tiny11RegExe` (a `%SystemRoot%\System32\reg.exe` absolute-path wrapper — child process, handle closed on exit). `Invoke-RegistryPatternZeroAction` is rewritten onto `Get-Tiny11RegValueNames`. No change to a successful build's output image — the same value names are matched and the same `reg add … /d 0` writes are issued.
- **v1.0.29's WIM-integrity gate and `-Save` retry are retained as defense-in-depth** (harmless, and they still guard a genuinely bad save or rare external interference), but they are no longer the mechanism — the reg.exe fix removes the in-process cause at the source.
- **Offline-servicing binaries now resolve from `%SystemRoot%\System32` by absolute path, not via `%PATH%` (Local-Dependencies-Only).** `reg.exe` (Hives `Invoke-RegCommand` + Core's inline tweak writes), `dism.exe` (Worker cleanup/export, Core appx enumeration/removal, `Actions.ProvisionedAppx`, and the `Start-CoreProcess` wrapper that backs dism/takeown/icacls), and `robocopy.exe` (Worker ISO copy) are all OS-intrinsic servicing tools that can't be vendored, so they're pinned to the System32 path — blocking a PATH-hijacked stand-in. New `Tiny11.Hives` resolvers `Get-Tiny11RegExePath` / `Get-Tiny11DismExePath`. (The post-boot cleanup script's `reg.exe` runs on the *target* machine — a separate context — and is unchanged.)

### Added

- **Regression guard `tests/Tiny11.OfflineHive.NoProvider.Drift.Tests.ps1`** — a static scan that fails if any `src` module reintroduces an `HK*:\z` offline-hive provider-drive reference. Plus reg.exe-only unit coverage for `Get-Tiny11RegValueNames`, `Test-Tiny11HiveLoaded`, and the rewritten `Invoke-RegistryPatternZeroAction`. (The v1.0.29 synthetic-WIM harness never loaded a registry hive, so it structurally could not have caught this class.)

### Notes

- **Process fix:** v1.0.29 shipped without a real end-to-end ISO build — only the content-agnostic synthetic-WIM test ran in CI — which is exactly why a real-build regression slipped through. v1.0.30 is gated on an actual `install.wim`-loading build (load hives → pattern-zero → unload → dismount-save) before release.
- v1.0.29 was **retracted**: its GitHub release and Velopack feed assets were deleted, so v1.0.28 remains the offered latest until v1.0.30 ships. The signed `v1.0.29` tag is retained — the repo's tag-protection ruleset blocks tag deletion.

## [1.0.29] - 2026-05-31

**Hardens the WIM commit path so a corrupt image can never ship silently, and adds real-DISM end-to-end test coverage for it.** Under transient host interference (Defender real-time scan, Windows Search indexer, Controlled Folder Access, a lingering handle), `Dismount-WindowsImage -Save` could partially commit `install.wim` — producing a valid-looking ISO that then failed Windows Setup at the file-copy step (the v1.0.26 "Windows installation has failed" symptom). The build now retries a failed save and verifies the saved/exported `install.wim` and `boot.wim`, aborting with a clear error rather than shipping a broken image. A new admin-gated synthetic-WIM harness validates the gate + retry against a real `New-WindowsImage` WIM (and runs for real in CI). No change to a successful build's output image. Pester 507/0, xUnit 140/0.
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ tiny11options/

- **PowerShell:** verb-noun cmdlet naming; `Set-StrictMode -Version Latest` at module top; `$ErrorActionPreference = 'Stop'`; no aliases in scripts (`Get-ChildItem`, not `gci`)
- **C# 13 / .NET 10:** nullable reference types enabled; file-scoped namespaces; primary constructors welcome; `dotnet format` clean before commit
- **No PowerShell string interpolation into native commands** — use parameter binding / argument arrays; `Invoke-RegCommand` (in `src/Tiny11.Registry.psm1`) wraps `reg.exe` safely
- **No PowerShell string interpolation into native commands** — use parameter binding / argument arrays; `Invoke-RegCommand` (in `src/Tiny11.Hives.psm1`) wraps `reg.exe` safely
- **Offline registry hives: `reg.exe` only — never the .NET registry provider.** Read/write loaded offline hives via `reg.exe` (`Invoke-RegCommand` / `Get-Tiny11RegValueNames` / `Test-Tiny11HiveLoaded`), never `Get-Item`/`Set-ItemProperty`/`Test-Path` on `HKLM:\z*`. The provider caches an in-process hive handle that survives `reg unload` and locks `Dismount-WindowsImage -Save` ("being used by another process"). Enforced by `tests/Tiny11.OfflineHive.NoProvider.Drift.Tests.ps1`. Build-process `reg.exe`/`dism.exe`/`robocopy.exe` resolve from the absolute `%SystemRoot%\System32` path (`Get-Tiny11RegExePath` / `Get-Tiny11DismExePath`), not `%PATH%`.
- **Logging:** use the launcher's bridge-routed logger; do not `Write-Host` in PS modules (breaks the UI bridge); use `Write-BridgeLog -Level Info/Warning/Error`
- **No AI-generated attribution lines** in commit messages

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ One blocked:
pwsh -NoProfile -File tests/Run-Tests.ps1
```

495 Pester tests (catalog parsing + schema validation, selection model + reconcile/lock logic, registry hive helpers, four action handlers including the post-boot online emitter shapes, action dispatcher, ISO mounting + edition enumeration, autounattend templating + drift detection, worker / Core dispatch, bridge protocol, WebView2 SDK detection, post-boot generator + helpers golden + Format-PSNamedParams + task XML + SetupComplete + Install + the v1.0.2 audit-bundle regression guards across A3 / A4 / A5 / A6 / A7 / A11 + boot.wim pipelineSucceeded wrap + Core Hives -Global + the v1.0.3 BCP-47 language regex coverage across every Windows 11 Language Pack tag including Serbian Latin + the v1.0.3 `registry-pattern-zero` action-type coverage from catalog completeness phase 2 + v1.0.7 takeown/icacls stderr noise-suppression guards + v1.0.8 audit-cleanup guards: registry pattern-zero scope + catalog hive/required-field validation + filesystem noise-filter anchored regex + orchestrator type-aware Build-RelaunchArgs / NonInteractive self-elevation refuse + v1.0.10 retargeting of the UI source-text Pester suite onto the v1.0.9 Step 1 two-column / Step 3 segmented-cards surface: canMoveForward forward-nav predicate gates on outputFilled + outputClean + Output ISO Step 1 field markup with `.req-asterisk` label + `aria-required` input + reserved `.error-slot`, replacing 9 deleted assertions against the pre-v1.0.9 Step 3 output-required-warning + outputMissing predicate + Build ISO tooltip + 4 anchor rewrites on Cleanup.Tests.ps1 for buildDisabled wiring moved into renderIdleCtaCard / build-error path running through renderErrorCard + 1 PostBootCleanup anchor rewrite onto stable input id literals; dead `.output-required-warning` CSS rules + their 3 retained CSS-rule tests pruned in the same release + v1.0.13 RefocusUpdateCheck coverage for the focus-based update re-check: UPDATE_CHECK_MIN_INTERVAL_MS 5-min throttle constant + lastUpdateCheckMs state init + boot-time stamp-before-dispatch order + window.focus listener registration + skip-if-applying / throttle-window / stamp-before-dispatch guard clauses + dispatch-shape match against the boot handshake) and 140 xUnit launcher tests (BuildHandlers / CleanupHandlers / EmbeddedResources drift / payload contracts + v1.0.3 ArchitectureGate rejection coverage for arm64 / arm / x86 hosts + v1.0.3 A13 HeadlessArgs parser + BuildLogPathResolver coverage + v1.0.7 AppVersion formatter + v1.0.8 ArgQuoting shared helper + bridge requestType echo + Process state cache-before-Dispose + v1.0.9 AutoScratchPath generator + PathValidationHandlers covering scratch + output path validation with writability probe + v1.0.11 AutoScratchPath relocation guards: path is under `<UserDocuments>\tiny11_outputs` + leaf uses `tiny11-<8charhex>` suffix pattern + helper is side-effect-free relative to disk).
521 Pester tests (catalog parsing + schema validation, selection model + reconcile/lock logic, registry hive helpers, four action handlers including the post-boot online emitter shapes, action dispatcher, ISO mounting + edition enumeration, autounattend templating + drift detection, worker / Core dispatch, bridge protocol, WebView2 SDK detection, post-boot generator + helpers golden + Format-PSNamedParams + task XML + SetupComplete + Install + the v1.0.2 audit-bundle regression guards across A3 / A4 / A5 / A6 / A7 / A11 + boot.wim pipelineSucceeded wrap + Core Hives -Global + the v1.0.3 BCP-47 language regex coverage across every Windows 11 Language Pack tag including Serbian Latin + the v1.0.3 `registry-pattern-zero` action-type coverage from catalog completeness phase 2 + v1.0.7 takeown/icacls stderr noise-suppression guards + v1.0.8 audit-cleanup guards: registry pattern-zero scope + catalog hive/required-field validation + filesystem noise-filter anchored regex + orchestrator type-aware Build-RelaunchArgs / NonInteractive self-elevation refuse + v1.0.10 retargeting of the UI source-text Pester suite onto the v1.0.9 Step 1 two-column / Step 3 segmented-cards surface: canMoveForward forward-nav predicate gates on outputFilled + outputClean + Output ISO Step 1 field markup with `.req-asterisk` label + `aria-required` input + reserved `.error-slot`, replacing 9 deleted assertions against the pre-v1.0.9 Step 3 output-required-warning + outputMissing predicate + Build ISO tooltip + 4 anchor rewrites on Cleanup.Tests.ps1 for buildDisabled wiring moved into renderIdleCtaCard / build-error path running through renderErrorCard + 1 PostBootCleanup anchor rewrite onto stable input id literals; dead `.output-required-warning` CSS rules + their 3 retained CSS-rule tests pruned in the same release + v1.0.13 RefocusUpdateCheck coverage for the focus-based update re-check: UPDATE_CHECK_MIN_INTERVAL_MS 5-min throttle constant + lastUpdateCheckMs state init + boot-time stamp-before-dispatch order + window.focus listener registration + skip-if-applying / throttle-window / stamp-before-dispatch guard clauses + dispatch-shape match against the boot handshake) and 140 xUnit launcher tests (BuildHandlers / CleanupHandlers / EmbeddedResources drift / payload contracts + v1.0.3 ArchitectureGate rejection coverage for arm64 / arm / x86 hosts + v1.0.3 A13 HeadlessArgs parser + BuildLogPathResolver coverage + v1.0.7 AppVersion formatter + v1.0.8 ArgQuoting shared helper + bridge requestType echo + Process state cache-before-Dispose + v1.0.9 AutoScratchPath generator + PathValidationHandlers covering scratch + output path validation with writability probe + v1.0.11 AutoScratchPath relocation guards: path is under `<UserDocuments>\tiny11_outputs` + leaf uses `tiny11-<8charhex>` suffix pattern + helper is side-effect-free relative to disk).

Note on the v1.0.1 "409 / 0" headline: the 2026-05-14 empirical audit reconciled this against `Invoke-Pester` runs in a worktree at each landing commit and revealed v1.0.1 actually shipped at **408 passed / 1 failed** (the prior figure reported `TotalCount` rather than `PassedCount`). The persistent failure was a CRLF-vs-LF byte-equal mismatch in the helpers golden fixture and healed in the v1.0.2 cycle by the A6 W2 line-ending-normalize fix. The full audit-verified chain is embedded in `CHANGELOG.md` `[1.0.1] > Test counts > Audit-verified Pester test count chain`.

Expand Down
2 changes: 1 addition & 1 deletion launcher/app.manifest
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.29.0" name="tiny11options"/>
<assemblyIdentity version="1.0.30.0" name="tiny11options"/>

<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
Expand Down
2 changes: 1 addition & 1 deletion launcher/tiny11options.Launcher.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
AppVersion.Current() -> window.__appVersion (see MainWindow.xaml.cs
injection block). Bumping <Version> automatically updates the UI
display — no manual sync step. -->
<Version>1.0.29</Version>
<Version>1.0.30</Version>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
Expand Down
9 changes: 7 additions & 2 deletions src/Tiny11.Actions.ProvisionedAppx.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ Set-StrictMode -Version Latest

$script:packageCache = @{}

# Absolute path to the OS (inbox) dism.exe -- resolved here, not via PATH, per the
# Local-Dependencies-Only rule (dism.exe is an OS-intrinsic servicing tool that can't be
# vendored; pinning the System32 path also blocks a PATH-hijacked dism.exe).
$script:Tiny11DismExe = Join-Path $env:SystemRoot 'System32\dism.exe'

function Clear-Tiny11AppxPackageCache {
[CmdletBinding()] param([string]$ScratchDir)
if ($ScratchDir) { $script:packageCache.Remove($ScratchDir) | Out-Null }
Expand All @@ -15,7 +20,7 @@ function Get-ProvisionedAppxPackagesFromImage {
return $script:packageCache[$ScratchDir]
}
$list = [System.Collections.Generic.List[string]]::new()
& 'dism.exe' '/English' "/image:$ScratchDir" '/Get-ProvisionedAppxPackages' |
& $script:Tiny11DismExe '/English' "/image:$ScratchDir" '/Get-ProvisionedAppxPackages' |
ForEach-Object {
if ($_ -match '^PackageName\s*:\s*(.+)$') { $list.Add($matches[1].Trim()) }
}
Expand All @@ -26,7 +31,7 @@ function Get-ProvisionedAppxPackagesFromImage {
function Invoke-DismRemoveAppx {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$ScratchDir, [Parameter(Mandatory)][string]$PackageName)
& 'dism.exe' '/English' "/image:$ScratchDir" '/Remove-ProvisionedAppxPackage' "/PackageName:$PackageName" | Out-Null
& $script:Tiny11DismExe '/English' "/image:$ScratchDir" '/Remove-ProvisionedAppxPackage' "/PackageName:$PackageName" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "dism /Remove-ProvisionedAppxPackage failed for $PackageName (exit $LASTEXITCODE)" }
if ($script:packageCache.ContainsKey($ScratchDir)) {
$null = $script:packageCache[$ScratchDir].Remove($PackageName)
Expand Down
31 changes: 15 additions & 16 deletions src/Tiny11.Actions.Registry.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -129,24 +129,23 @@ function Invoke-RegistryPatternZeroAction {
}

$mountKey = Get-Tiny11HiveMountKey -Hive $Action.hive # native reg.exe form: HKLM\zNTUSER
$psPath = "HKLM:\z$($Action.hive)\$($Action.key)" # PSDrive form: HKLM:\zNTUSER\<key>

if (-not (Test-Path -LiteralPath $psPath)) {
# Source ISO doesn't have this key populated -- legitimate no-op for
# source ISOs that ship without the CDM key (rare but valid).
return
}

# v1.0.8 audit BLOCKER ps-modules B1: use Get-Item -LiteralPath | Property
# to read the actual registry value names array from the provider, without
# PSObject metadata pollution (PSPath, PSChildName, PSDrive, PSProvider,
# PSParentPath) that Get-ItemProperty | Get-Member would also surface.
$allNames = @(Get-Item -LiteralPath $psPath -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty Property)
$names = @($allNames | Where-Object { $_ -like $Action.namePattern })
$fullKey = "$mountKey\$($Action.key)" # HKLM\zNTUSER\<key>

# Enumerate matching value names via reg.exe (Get-Tiny11RegValueNames), NEVER the .NET
# registry provider. Through v1.0.29 this used Test-Path + Get-Item on the
# "HKLM:\z<hive>\..." provider drive; the provider caches an in-process RegistryKey
# handle to the loaded offline NTUSER hive that survives `reg unload` and locks the
# mount at Dismount-WindowsImage -Save ("being used by another process" -- the build
# break this fixes). reg.exe runs in a child process whose handle dies on exit, so
# nothing in the long-lived build process ever holds the hive open -- the reg.exe-only
# pattern of upstream tiny11builder and Microsoft's offline-servicing docs. An absent
# key yields no names (reg query exit 1): the legitimate no-op for source ISOs that
# ship without the CDM key (B1: reg query also avoids the PSObject metadata pollution
# that the old Get-ItemProperty | Get-Member form had to filter out).
$names = Get-Tiny11RegValueNames -Key $fullKey -NamePattern $Action.namePattern

foreach ($name in $names) {
Invoke-RegCommand 'add' "$mountKey\$($Action.key)" '/v' $name '/t' $Action.valueType '/d' '0' '/f' | Out-Null
Invoke-RegCommand 'add' $fullKey '/v' $name '/t' $Action.valueType '/d' '0' '/f' | Out-Null
}
}

Expand Down
Loading