diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index da100e0..33209b2 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -117,12 +117,20 @@ jobs: shell: pwsh run: ./tests/windows/assert-migration.ps1 + - name: Assert current-user fonts + shell: pwsh + run: ./tests/windows/assert-fonts.ps1 -RepoRoot $env:GITHUB_WORKSPACE + - shell: pwsh run: ./tests/windows/assert-state.ps1 -RepoRoot $env:GITHUB_WORKSPACE -ExpectedCommit $env:GITHUB_SHA - shell: cmd run: call "%GITHUB_WORKSPACE%\tests\windows\create-update-fixture.cmd" - shell: cmd run: call "%GITHUB_WORKSPACE%\update.cmd" + - name: Reassert current-user fonts + shell: pwsh + run: ./tests/windows/assert-fonts.ps1 -RepoRoot $env:GITHUB_WORKSPACE + - shell: pwsh run: ./tests/windows/assert-state.ps1 -RepoRoot $env:GITHUB_WORKSPACE -UpdateMarker $env:UPDATE_MARKER @@ -155,11 +163,19 @@ jobs: shell: cmd run: call "%GITHUB_WORKSPACE%\scripts\windows\invoke-ps-script.cmd" "%GITHUB_WORKSPACE%\tests\windows\assert-migration.ps1" + - name: Assert current-user fonts under AllSigned + shell: cmd + run: call "%GITHUB_WORKSPACE%\scripts\windows\invoke-ps-script.cmd" "%GITHUB_WORKSPACE%\tests\windows\assert-fonts.ps1" -RepoRoot "%GITHUB_WORKSPACE%" + - shell: cmd run: call "%GITHUB_WORKSPACE%\scripts\windows\invoke-ps-script.cmd" "%GITHUB_WORKSPACE%\tests\windows\assert-allsigned.ps1" -RepoRoot "%GITHUB_WORKSPACE%" -ThumbprintFile "%RUNNER_TEMP%\dotfiles-cert-thumbprint.txt" - shell: cmd run: call "%GITHUB_WORKSPACE%\tests\windows\create-update-fixture.cmd" - shell: cmd run: call "%GITHUB_WORKSPACE%\update.cmd" + - name: Reassert current-user fonts under AllSigned + shell: cmd + run: call "%GITHUB_WORKSPACE%\scripts\windows\invoke-ps-script.cmd" "%GITHUB_WORKSPACE%\tests\windows\assert-fonts.ps1" -RepoRoot "%GITHUB_WORKSPACE%" + - shell: cmd run: call "%GITHUB_WORKSPACE%\scripts\windows\invoke-ps-script.cmd" "%GITHUB_WORKSPACE%\tests\windows\assert-allsigned.ps1" -RepoRoot "%GITHUB_WORKSPACE%" -ThumbprintFile "%RUNNER_TEMP%\dotfiles-cert-thumbprint.txt" -UpdateMarker "%UPDATE_MARKER%" diff --git a/README.md b/README.md index 8f4ae50..2acff13 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Windows PowerShell 5.1 is only used to download and launch the CMD bootstrap. Th The Windows bootstrap requires WinGet. Before applying chezmoi, it removes only broken links or reparse points that block a currently managed destination path. Parent components under the user profile are checked as well, but valid links are never traversed and unrelated broken links are left untouched. If App Installer exists but WinGet is not registered for the current user, it attempts current-user App Installer registration. If corporate policy disables WinGet, it stops with an error. Bootstrap installs only Git, PowerShell 7, and chezmoi when they are missing; the platform installer owns the remaining application catalog. -Baseline CLI packages are declared in `scripts/windows/managed-apps.csv` and are installed with WinGet in user scope. An interactive run also installs workstation applications, fonts, Windows Terminal customization, and offers optional applications. `DOTFILES_NONINTERACTIVE=1` installs the complete baseline but skips those interactive/workstation customizations. +Baseline CLI packages are declared in `scripts/windows/managed-apps.csv` and are installed with WinGet in user scope. Cascadia Code plus the Cascadia Code and Cascadia Mono Nerd Font variants are installed for the current user as part of the Windows baseline, including non-interactive runs. An interactive run additionally installs workstation applications, configures Windows Terminal, and offers optional applications. `DOTFILES_NONINTERACTIVE=1` installs the complete CLI/font baseline while skipping those interactive/workstation customizations. The bootstrap does not require administrator rights or Developer Mode. It invokes PowerShell 7 with `-NoProfile`; an effective `AllSigned` policy is supported automatically. Each machine reuses or creates its own current-user Code Signing certificate and trusts its public certificate locally. No private key, PFX, or KeePass dependency is used. @@ -53,7 +53,7 @@ For dotfiles plus installer-managed package/application/module updates, use `upd The `Validate dotfiles` workflow exercises Debian, Arch Linux, Windows with its normal execution policy, and Windows with a simulated current-user `AllSigned` policy. GitHub Actions only orchestrates the scenarios; reusable fixture and assertion logic lives under `tests/`. -Integration jobs bootstrap from a temporary local bare Git remote containing the exact commit under test. They run the real non-interactive baseline, validate installed CLI tools, exercise create-only Codex files and update wrappers, check idempotent chezmoi apply, and verify clean source/checkout state. Static validation includes ShellCheck, PowerShell parsing, manifest checks, chezmoi template evaluation, bridge-payload synchronization, and pinned `actionlint` validation of the workflow. +Integration jobs bootstrap from a temporary local bare Git remote containing the exact commit under test. They run the real non-interactive baseline, validate installed CLI tools and current-user fonts, exercise create-only Codex files and update wrappers, check idempotent chezmoi apply, and verify clean source/checkout state. Windows font assertions verify both the files under `%LOCALAPPDATA%\Microsoft\Windows\Fonts` and their matching `HKCU\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts` entries. Static validation includes ShellCheck, PowerShell parsing, manifest checks, chezmoi template evaluation, bridge-payload synchronization, and pinned `actionlint` validation of the workflow. The Windows AllSigned job validates current-user certificate creation and reuse, Authenticode signing, the CMD execution bridge, PowerShell profile startup, and managed module loading. When PowerShell 7 requires signed scripts, the bridge uses inbox Windows PowerShell only for Authenticode signing and executes the resulting signed script with PowerShell 7 under the effective policy. The hosted runner is an administrator with UAC disabled, so CI trusts the test certificate through `LocalMachine\Root` plus `CurrentUser\TrustedPublisher`; runtime helpers also accept `CurrentUser\Root` for the real non-admin path. Corporate GPO/MDM/AppLocker/WDAC policy, enterprise App Installer policy, and a true non-admin corporate Windows 11 token still require validation on a managed machine. diff --git a/docs/windows-fonts.md b/docs/windows-fonts.md new file mode 100644 index 0000000..0da4ca3 --- /dev/null +++ b/docs/windows-fonts.md @@ -0,0 +1,13 @@ +# Windows fonts + +The Windows baseline installs these font families for the current user: + +- Microsoft Cascadia Code +- Caskaydia Cove Nerd Font (Nerd Fonts patched Cascadia Code) +- Caskaydia Mono Nerd Font (Nerd Fonts patched Cascadia Mono) + +Expected filename globs are declared in `scripts/windows/managed-fonts.txt`. + +The installer downloads fonts into the chezmoi working tree's ignored `fonts/` directory, copies the resulting font files to `%LOCALAPPDATA%\Microsoft\Windows\Fonts`, and maintains matching entries under `HKCU\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts`. + +The Windows integration jobs validate the installed files and registry entries after both bootstrap and update, under the normal execution policy and `AllSigned`. diff --git a/install.ps1 b/install.ps1 index 50555f7..f323d94 100644 --- a/install.ps1 +++ b/install.ps1 @@ -281,51 +281,147 @@ function Install-OptionalApps { Refresh-Path } +function Expand-FontArchive { + param( + [Parameter(Mandatory)][string]$Archive, + [Parameter(Mandatory)][string]$Destination + ) + + $tar = Get-Command tar.exe -ErrorAction SilentlyContinue + if ($null -eq $tar) { + throw "tar.exe is required to extract font archives." + } + + Remove-Item -LiteralPath $Destination -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + + & $tar.Source -xf $Archive -C $Destination + if ($LASTEXITCODE -ne 0) { + throw "Font archive extraction failed for $Archive (exit code $LASTEXITCODE)." + } +} + +function Get-ManagedFontPatterns { + $manifest = Join-Path $RepoRoot "scripts\windows\managed-fonts.txt" + if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { + throw "The managed font manifest is missing from $RepoRoot." + } + + return @(Get-Content -LiteralPath $manifest | Where-Object { + $_.Trim() -and -not $_.Trim().StartsWith("#") + } | ForEach-Object { $_.Trim() }) +} + function Download-Fonts { $fonts = Join-Path $RepoRoot "fonts" New-Item -ItemType Directory -Force -Path $fonts | Out-Null - if (-not (Test-Path (Join-Path $fonts "CascadiaCode.ttf"))) { + if (@(Get-ChildItem -LiteralPath $fonts -Filter "CascadiaCode*.ttf" -File -ErrorAction SilentlyContinue).Count -eq 0) { $release = Invoke-RestMethod -Uri "https://api.github.com/repos/microsoft/cascadia-code/releases/latest" -Headers @{ "User-Agent" = "PowerShell" } $asset = @($release.assets | Where-Object name -Match '^CascadiaCode-.*\.zip$' | Select-Object -First 1) - if ($asset.Count -ne 1) { throw "Unable to locate the Cascadia Code ZIP asset in the latest GitHub release." } + if ($asset.Count -ne 1) { + throw "Unable to locate the Cascadia Code ZIP asset in the latest GitHub release." + } - $zip = Join-Path $fonts "CascadiaCode.zip" - $extract = Join-Path $fonts "CascadiaCode" - Invoke-WebRequest -Uri $asset[0].browser_download_url -OutFile $zip - Expand-Archive $zip -DestinationPath $extract -Force - Remove-Item -Recurse -Force (Join-Path $extract "ttf\static") -ErrorAction SilentlyContinue - Get-ChildItem -Path $extract -Filter *.ttf -Recurse -File | Move-Item -Destination $fonts -Force - Remove-Item -Recurse -Force $zip, $extract + $archive = Join-Path $fonts "CascadiaCode.zip" + $extract = Join-Path $fonts ".extract-CascadiaCode" + try { + Write-Host "Downloading Cascadia Code..." -ForegroundColor Cyan + Invoke-WebRequest -Uri $asset[0].browser_download_url -OutFile $archive + Expand-FontArchive -Archive $archive -Destination $extract + Remove-Item -Recurse -Force (Join-Path $extract "ttf\static") -ErrorAction SilentlyContinue + + $fontFiles = @(Get-ChildItem -LiteralPath $extract -Filter *.ttf -Recurse -File) + if ($fontFiles.Count -eq 0) { + throw "Cascadia Code archive did not contain any TTF files." + } + $fontFiles | Move-Item -Destination $fonts -Force + } + finally { + Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $extract -Recurse -Force -ErrorAction SilentlyContinue + } } - $nerdRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/ryanoasis/nerd-fonts/releases/latest" -Headers @{ "User-Agent" = "PowerShell" } foreach ($font in @( - @{ folder = (Join-Path $fonts "CaskaydiaCoveNerdFont"); filename = "CascadiaCode" }, - @{ folder = (Join-Path $fonts "CaskaydiaMonoNerdFont"); filename = "CascadiaMono" } + @{ Name = "Caskaydia Cove Nerd Font"; Asset = "CascadiaCode"; Pattern = "CaskaydiaCove*.ttf" }, + @{ Name = "Caskaydia Mono Nerd Font"; Asset = "CascadiaMono"; Pattern = "CaskaydiaMono*.ttf" } )) { - if (Test-Path "$($font.folder)-Regular.ttf") { continue } - $zip = "$($font.folder).zip" - Invoke-WebRequest -Uri "https://github.com/ryanoasis/nerd-fonts/releases/download/$($nerdRelease.tag_name)/$($font.filename).zip" -OutFile $zip - Expand-Archive $zip -DestinationPath $font.folder -Force - Get-ChildItem -Path $font.folder -Filter *.ttf -Recurse -File | Move-Item -Destination $fonts -Force - Remove-Item -Recurse -Force $zip, $font.folder + if (@(Get-ChildItem -LiteralPath $fonts -Filter $font.Pattern -File -ErrorAction SilentlyContinue).Count -gt 0) { + continue + } + + $archive = Join-Path $fonts "$($font.Asset).tar.xz" + $extract = Join-Path $fonts ".extract-$($font.Asset)" + try { + Write-Host "Downloading $($font.Name)..." -ForegroundColor Cyan + Invoke-WebRequest -Uri "https://github.com/ryanoasis/nerd-fonts/releases/latest/download/$($font.Asset).tar.xz" -OutFile $archive + Expand-FontArchive -Archive $archive -Destination $extract + + $fontFiles = @(Get-ChildItem -LiteralPath $extract -Filter *.ttf -Recurse -File) + if ($fontFiles.Count -eq 0) { + throw "$($font.Name) archive did not contain any TTF files." + } + $fontFiles | Move-Item -Destination $fonts -Force + } + finally { + Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $extract -Recurse -Force -ErrorAction SilentlyContinue + } + } + + foreach ($requiredPattern in Get-ManagedFontPatterns) { + if (@(Get-ChildItem -LiteralPath $fonts -Filter $requiredPattern -File -ErrorAction SilentlyContinue).Count -eq 0) { + throw "Expected font files are missing after download: $requiredPattern" + } } } function Install-UserFonts { $sourceDir = Join-Path $RepoRoot "fonts" $userFontsDir = Join-Path $env:LOCALAPPDATA "Microsoft\Windows\Fonts" - $fontRegistryKey = "HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" - if (-not (Test-Path -LiteralPath $sourceDir -PathType Container)) { throw "Font source directory not found: $sourceDir" } + $fontRegistrySubKey = "SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" + + if (-not (Test-Path -LiteralPath $sourceDir -PathType Container)) { + throw "Font source directory not found: $sourceDir" + } + New-Item -ItemType Directory -Path $userFontsDir -Force | Out-Null - $sourceFonts = @(Get-ChildItem -Path $sourceDir -Include *.otc,*.otf,*.ttc,*.ttf -Recurse -File) - foreach ($font in $sourceFonts | Sort-Object Name -Unique) { - $destination = Join-Path $userFontsDir $font.Name - if (Test-Path -LiteralPath $destination) { continue } - Copy-Item -LiteralPath $font.FullName -Destination $destination - New-ItemProperty -Path $fontRegistryKey -Name "$($font.Name) (dotfiles)" -Value $destination -PropertyType String -Force | Out-Null + + $sourceFonts = @(Get-ChildItem -Path $sourceDir -Include *.otc,*.otf,*.ttc,*.ttf -Recurse -File | Sort-Object Name -Unique) + if ($sourceFonts.Count -eq 0) { + throw "No font files were downloaded to $sourceDir." + } + + $registryKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($fontRegistrySubKey) + if ($null -eq $registryKey) { + throw "Unable to open the current-user font registry key." } + + try { + foreach ($font in $sourceFonts) { + $destination = Join-Path $userFontsDir $font.Name + $registryName = "$($font.Name) (dotfiles)" + + if (-not (Test-Path -LiteralPath $destination -PathType Leaf)) { + Copy-Item -LiteralPath $font.FullName -Destination $destination + } + + $registeredPath = $registryKey.GetValue( + $registryName, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + if ([string]$registeredPath -ne $destination) { + $registryKey.SetValue($registryName, $destination, [Microsoft.Win32.RegistryValueKind]::String) + } + } + } + finally { + $registryKey.Dispose() + } + + Write-Host "Installed/registered $($sourceFonts.Count) current-user font files." -ForegroundColor Green } function Configure-Git { @@ -357,10 +453,8 @@ function Configure-Wsl { Refresh-Path Check-RequiredApps -if (-not $NonInteractive) { - Download-Fonts - Install-UserFonts -} +Download-Fonts +Install-UserFonts Configure-Git Install-MustHaveApps @@ -370,7 +464,7 @@ if (-not $NonInteractive) { Install-OptionalApps } else { - Write-Host "Skipping fonts, terminal configuration, and optional applications in non-interactive mode." -ForegroundColor Yellow + Write-Host "Skipping terminal configuration and optional applications in non-interactive mode." -ForegroundColor Yellow } Configure-Wsl diff --git a/scripts/windows/managed-fonts.txt b/scripts/windows/managed-fonts.txt new file mode 100644 index 0000000..24504df --- /dev/null +++ b/scripts/windows/managed-fonts.txt @@ -0,0 +1,4 @@ +# Expected filename globs for the Windows font baseline. +CascadiaCode*.ttf +CaskaydiaCove*.ttf +CaskaydiaMono*.ttf diff --git a/tests/static/validate-architecture.py b/tests/static/validate-architecture.py index ebfa350..19da9f6 100644 --- a/tests/static/validate-architecture.py +++ b/tests/static/validate-architecture.py @@ -41,12 +41,14 @@ def read_text(path: str) -> str: "scripts/windows/managed-apps.csv", "scripts/windows/cleanup-broken-managed-links.ps1", "scripts/windows/managed-modules.txt", + "scripts/windows/managed-fonts.txt", "scripts/windows/invoke-ps-script.cmd", "scripts/windows/invoke-ps-script-bridge.ps1", "scripts/windows/signing.ps1", "scripts/windows/deploy-pwsh.ps1", "home/.chezmoiscripts/run_after_90-deploy-pwsh.cmd.tmpl", "tests/windows/assert-migration.ps1", + "tests/windows/assert-fonts.ps1", ) for relative in required_paths: if not (ROOT / relative).exists(): diff --git a/tests/windows/README.md b/tests/windows/README.md new file mode 100644 index 0000000..5fc1824 --- /dev/null +++ b/tests/windows/README.md @@ -0,0 +1,10 @@ +# Windows integration tests + +The Windows jobs exercise the real non-interactive bootstrap and update paths under both the normal execution policy and `AllSigned`. + +- `assert-migration.ps1` verifies recovery from broken managed links without modifying unrelated broken links. +- `assert-fonts.ps1` verifies that every downloaded baseline font exists in the current-user Fonts directory and has a matching HKCU font registration. +- `assert-state.ps1` validates the normal-policy runtime state. +- `assert-allsigned.ps1` validates signatures, trusted certificate state, modules, and profile startup under `AllSigned`. + +Scripts executed after `AllSigned` becomes effective must run through the repository CMD signing bridge. diff --git a/tests/windows/assert-fonts.ps1 b/tests/windows/assert-fonts.ps1 new file mode 100644 index 0000000..a843fe5 --- /dev/null +++ b/tests/windows/assert-fonts.ps1 @@ -0,0 +1,104 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot +) + +$ErrorActionPreference = "Stop" + +function Fail([string]$Message) { + throw "ASSERTION FAILED: $Message" +} + +function Resolve-ChezmoiRepoRoot { + $sourcePath = ((& chezmoi.exe source-path 2>&1) -join "").Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($sourcePath)) { + Fail "unable to resolve chezmoi source path" + } + + foreach ($candidate in @( + $sourcePath, + [IO.Path]::GetDirectoryName($sourcePath) + )) { + if ([string]::IsNullOrWhiteSpace($candidate)) { + continue + } + if ( + (Test-Path -LiteralPath (Join-Path $candidate "install.ps1") -PathType Leaf) -and + (Test-Path -LiteralPath (Join-Path $candidate "fonts") -PathType Container) + ) { + return [IO.Path]::GetFullPath($candidate) + } + } + + Fail "unable to resolve the chezmoi working tree containing downloaded fonts from '$sourcePath'" +} + +$sourceRepoRoot = Resolve-ChezmoiRepoRoot +$sourceDir = Join-Path $sourceRepoRoot "fonts" +$userFontsDir = Join-Path $env:LOCALAPPDATA "Microsoft\Windows\Fonts" +$fontRegistrySubKey = "SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" +$manifest = Join-Path $RepoRoot "scripts\windows\managed-fonts.txt" + +if (-not (Test-Path -LiteralPath $userFontsDir -PathType Container)) { + Fail "current-user font directory is missing: $userFontsDir" +} +if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { + Fail "managed font manifest is missing: $manifest" +} + +$sourceFonts = @( + Get-ChildItem -Path $sourceDir -Include *.otc,*.otf,*.ttc,*.ttf -Recurse -File | + Sort-Object Name -Unique +) +if ($sourceFonts.Count -eq 0) { + Fail "no downloaded fonts were found in $sourceDir" +} + +$requiredPatterns = @( + Get-Content -LiteralPath $manifest | + Where-Object { $_.Trim() -and -not $_.Trim().StartsWith("#") } | + ForEach-Object { $_.Trim() } +) + +foreach ($requiredPattern in $requiredPatterns) { + if (@($sourceFonts | Where-Object Name -Like $requiredPattern).Count -eq 0) { + Fail "expected font family is missing from the downloaded source: $requiredPattern" + } +} + +$registryKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($fontRegistrySubKey, $false) +if ($null -eq $registryKey) { + Fail "current-user font registry key is missing: HKCU\$fontRegistrySubKey" +} + +try { + foreach ($font in $sourceFonts) { + $destination = Join-Path $userFontsDir $font.Name + if (-not (Test-Path -LiteralPath $destination -PathType Leaf)) { + Fail "font was not installed for the current user: $destination" + } + + $registryName = "$($font.Name) (dotfiles)" + $registeredPath = $registryKey.GetValue( + $registryName, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + if ($null -eq $registeredPath) { + Fail "font registry entry is missing: $registryName" + } + + if (-not [string]::Equals( + [IO.Path]::GetFullPath([string]$registeredPath), + [IO.Path]::GetFullPath($destination), + [StringComparison]::OrdinalIgnoreCase + )) { + Fail "font registry entry '$registryName' points to '$registeredPath' instead of '$destination'" + } + } +} +finally { + $registryKey.Dispose() +} + +Write-Host "Windows font assertions passed for $($sourceFonts.Count) installed font files from $sourceRepoRoot."