Change & fix #78
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build | |
| on: | |
| push: | |
| branches: | |
| - main | |
| tags: | |
| - 'v*' | |
| pull_request: | |
| branches: | |
| - main | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: build-${{ github.ref }} | |
| # Avoid interrupting a release while its tag and assets are being published. | |
| cancel-in-progress: false | |
| jobs: | |
| validate-scripts: | |
| name: Validate PowerShell Scripts | |
| runs-on: windows-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Harden Runner | |
| uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2 | |
| with: | |
| egress-policy: audit | |
| - name: Checkout repository | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Validate PowerShell script syntax | |
| run: | | |
| $scripts = @( | |
| "scripts/Gaming optimizations/Gaming-Optimizations.ps1", | |
| "scripts/Network optimizations/Network-Optimizations.ps1", | |
| "scripts/Performance/performance.ps1", | |
| "scripts/Privacy Security/privacy.ps1", | |
| "scripts/Defender/defender.ps1", | |
| "scripts/Exploit Protection/exploit-protection.ps1", | |
| "scripts/Device Guard/device-guard.ps1", | |
| "scripts/Network Security/network-security.ps1", | |
| "scripts/System Security/system-security.ps1", | |
| "scripts/Debloat/debloat.ps1", | |
| "scripts/Maintenance/maintenance.ps1", | |
| "scripts/Customize/Customize.ps1", | |
| "scripts/Windows Update/windows-update.ps1", | |
| "scripts/Edge/Edge.ps1", | |
| "scripts/Privacy Security/revert-privacy.ps1", | |
| "scripts/Defender/revert-defender.ps1", | |
| "scripts/Exploit Protection/revert-exploit-protection.ps1", | |
| "scripts/Device Guard/revert-device-guard.ps1", | |
| "scripts/Network Security/revert-network-security.ps1", | |
| "scripts/System Security/revert-system-security.ps1", | |
| "scripts/create_restore_point.ps1", | |
| "scripts/CommonFunctions.ps1" | |
| ) | |
| $errors = 0 | |
| foreach ($script in $scripts) { | |
| if (Test-Path $script) { | |
| Write-Host "Validating: $script" | |
| $parseErrors = $null | |
| $tokens = $null | |
| [void][System.Management.Automation.Language.Parser]::ParseFile($script, [ref]$tokens, [ref]$parseErrors) | |
| if ($parseErrors.Count -gt 0) { | |
| Write-Error "Syntax error in $script" | |
| $parseErrors | ForEach-Object { Write-Error $_.Message } | |
| $errors++ | |
| } else { | |
| Write-Host "Syntax OK: $script" | |
| } | |
| } else { | |
| Write-Error "Script not found: $script" | |
| $errors++ | |
| } | |
| } | |
| if ($errors -gt 0) { | |
| exit 1 | |
| } | |
| shell: pwsh | |
| - name: Verify script parameters and structure | |
| run: | | |
| $scripts = @( | |
| "scripts/Gaming optimizations/Gaming-Optimizations.ps1", | |
| "scripts/Network optimizations/Network-Optimizations.ps1", | |
| "scripts/Performance/performance.ps1", | |
| "scripts/Privacy Security/privacy.ps1", | |
| "scripts/Defender/defender.ps1", | |
| "scripts/Exploit Protection/exploit-protection.ps1", | |
| "scripts/Device Guard/device-guard.ps1", | |
| "scripts/Network Security/network-security.ps1", | |
| "scripts/System Security/system-security.ps1", | |
| "scripts/Debloat/debloat.ps1", | |
| "scripts/Maintenance/maintenance.ps1", | |
| "scripts/Customize/Customize.ps1", | |
| "scripts/Windows Update/windows-update.ps1", | |
| "scripts/Edge/Edge.ps1" | |
| ) | |
| foreach ($script in $scripts) { | |
| Write-Host "Checking: $script" | |
| $content = Get-Content $script -Raw | |
| # Verify Action parameter (param block + [string]$Action) | |
| if ($content -notmatch 'param\s*\(' -or $content -notmatch '\[string\]\$Action') { | |
| Write-Error "Missing -Action parameter in $script" | |
| exit 1 | |
| } | |
| # Verify logging function available (defined locally or via CommonFunctions.ps1) | |
| if ($content -notmatch 'function Write-PTWLog' -and $content -notmatch 'CommonFunctions\.ps1') { | |
| Write-Error "Missing Write-PTWLog function or CommonFunctions.ps1 import in $script" | |
| exit 1 | |
| } | |
| # Verify NO Read-Host (non-interactive) — (?m) makes ^ match start of each line | |
| if ($content -match '(?m)^\s*(?:\$\w+\s*=\s*)?Read-Host') { | |
| Write-Error "Script should be non-interactive (no Read-Host): $script" | |
| exit 1 | |
| } | |
| Write-Host "Structure OK: $script" | |
| } | |
| shell: pwsh | |
| - name: Verify index.txt | |
| run: | | |
| $indexFile = "scripts/index.txt" | |
| $scriptsDir = "scripts" | |
| if (-not (Test-Path $indexFile)) { | |
| Write-Error "index.txt not found" | |
| exit 1 | |
| } | |
| $indexContent = Get-Content $indexFile | ForEach-Object { $_.Trim() } | Where-Object { $_ } | |
| # Every file under scripts/ (except index.txt) must be listed in index.txt or ResourceExtractor can't extract it. | |
| $scriptsRoot = (Resolve-Path "scripts").Path | |
| $actual = Get-ChildItem -Path "scripts" -Recurse -File | | |
| Where-Object { $_.Name -ne 'index.txt' } | | |
| ForEach-Object { $_.FullName.Substring($scriptsRoot.Length + 1) -replace '\\','/' } | |
| $missing = $actual | Where-Object { $indexContent -notcontains $_ } | |
| if ($missing) { | |
| Write-Error "Files missing from index.txt: $($missing -join ', ')" | |
| exit 1 | |
| } | |
| # Reverse check: every entry in index.txt must correspond to a real file | |
| foreach ($entry in $indexContent) { | |
| $trimmed = $entry.Trim() | |
| if (-not $trimmed) { continue } | |
| $fullPath = Join-Path $scriptsDir $trimmed | |
| if (-not (Test-Path $fullPath)) { | |
| Write-Error "index.txt references non-existent file: $trimmed" | |
| exit 1 | |
| } | |
| } | |
| Write-Host "index.txt validation passed" | |
| shell: pwsh | |
| - name: Verify script checksums are current | |
| run: | | |
| # Every shipped file's hash must match file-checksums.json (regenerate with the command in the manifest notes). | |
| $manifest = Get-Content "scripts/file-checksums.json" -Raw | ConvertFrom-Json | |
| $bad = 0 | |
| foreach ($prop in $manifest.scripts.PSObject.Properties) { | |
| $path = Join-Path "scripts" $prop.Name | |
| if (-not (Test-Path $path)) { | |
| Write-Error "Checksummed file missing: $($prop.Name)" | |
| $bad++ | |
| continue | |
| } | |
| $actual = (Get-FileHash -Algorithm SHA256 -Path $path).Hash | |
| if ($actual -ne $prop.Value) { | |
| Write-Error "Checksum mismatch for $($prop.Name): expected $($prop.Value), actual $actual. Update scripts/file-checksums.json." | |
| $bad++ | |
| } | |
| } | |
| if ($bad -gt 0) { | |
| Write-Error "$bad script checksum(s) stale or missing. Regenerate file-checksums.json." | |
| exit 1 | |
| } | |
| # Every indexed payload must be attested, regardless of file extension. | |
| $shipped = Get-Content "scripts/index.txt" | ForEach-Object { $_.Trim() } | | |
| Where-Object { $_ -and $_ -ne 'file-checksums.json' -and $_ -ne 'index.txt' } | |
| $keys = $manifest.scripts.PSObject.Properties.Name | |
| $unattested = $shipped | Where-Object { $keys -notcontains $_ } | |
| if ($unattested) { | |
| Write-Error "Shipped files missing from file-checksums.json: $($unattested -join ', '). Add them with their SHA256." | |
| exit 1 | |
| } | |
| Write-Host "[+] All script checksums current and every payload is attested" | |
| shell: pwsh | |
| functional-testing: | |
| name: Functional Testing | |
| runs-on: windows-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Harden Runner | |
| uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2 | |
| with: | |
| egress-policy: audit | |
| - name: Checkout repository | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Cache PSScriptAnalyzer module | |
| uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 | |
| with: | |
| path: ~/Documents/PowerShell/Modules/PSScriptAnalyzer | |
| key: psscriptanalyzer-windows-1.22.0 | |
| - name: Run PSScriptAnalyzer | |
| run: | | |
| # Keep this version in sync with the cache key. | |
| $psaVersion = '1.22.0' | |
| if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer | Where-Object { $_.Version -eq $psaVersion })) { | |
| Install-Module -Name PSScriptAnalyzer -RequiredVersion $psaVersion -Force -Scope CurrentUser | |
| } | |
| Import-Module PSScriptAnalyzer -RequiredVersion $psaVersion | |
| $scripts = @( | |
| Get-ChildItem -Path "scripts" -Filter "*.ps1" -Recurse | |
| Get-ChildItem -Path "tools" -Filter "*.ps1" -Recurse | |
| ) | |
| Write-Host "Analyzing $($scripts.Count) PowerShell scripts..." | |
| $excludeRules = @( | |
| 'PSAvoidUsingWriteHost', | |
| 'PSUseShouldProcessForStateChangingFunctions', | |
| 'PSAvoidGlobalVars' | |
| ) | |
| $errors = 0 | |
| $warnings = 0 | |
| foreach ($script in $scripts) { | |
| $results = Invoke-ScriptAnalyzer -Path $script.FullName -ExcludeRule $excludeRules -Severity @('Error', 'Warning') | |
| foreach ($result in $results) { | |
| $shortPath = $script.FullName -replace '.*scripts\\', 'scripts\' | |
| if ($result.Severity -eq 'Error') { | |
| Write-Error "[$($result.Severity)] $shortPath`:$($result.Line) - $($result.RuleName): $($result.Message)" | |
| $errors++ | |
| } else { | |
| Write-Warning "[$($result.Severity)] $shortPath`:$($result.Line) - $($result.RuleName): $($result.Message)" | |
| $warnings++ | |
| } | |
| } | |
| } | |
| Write-Host "" | |
| Write-Host "[*] PSScriptAnalyzer: $errors errors, $warnings warnings" | |
| if (($errors + $warnings) -gt 0) { | |
| Write-Error "PSScriptAnalyzer found $errors errors and $warnings warnings. Fix before merging." | |
| exit 1 | |
| } | |
| Write-Host "[+] PSScriptAnalyzer passed" | |
| shell: pwsh | |
| - name: Verify dynamic vendor download signatures | |
| if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' | |
| run: ./tools/Test-DynamicDownloadSignatures.ps1 | |
| shell: pwsh | |
| - name: Verify dynamic vendor download map is in sync (no downloads) | |
| # Always-on gate: confirms the download map matches the scripts without fetching anything. | |
| run: ./tools/Test-DynamicDownloadSignatures.ps1 -MapSyncOnly | |
| shell: pwsh | |
| - name: Verify non-interactive script architecture | |
| run: | | |
| Write-Host "Testing non-interactive action dispatcher architecture..." | |
| $scripts = @( | |
| "scripts/Gaming optimizations/Gaming-Optimizations.ps1", | |
| "scripts/Network optimizations/Network-Optimizations.ps1", | |
| "scripts/Performance/performance.ps1", | |
| "scripts/Privacy Security/privacy.ps1", | |
| "scripts/Defender/defender.ps1", | |
| "scripts/Exploit Protection/exploit-protection.ps1", | |
| "scripts/Device Guard/device-guard.ps1", | |
| "scripts/Network Security/network-security.ps1", | |
| "scripts/System Security/system-security.ps1", | |
| "scripts/Debloat/debloat.ps1", | |
| "scripts/Maintenance/maintenance.ps1", | |
| "scripts/Customize/Customize.ps1", | |
| "scripts/Windows Update/windows-update.ps1", | |
| "scripts/Edge/Edge.ps1" | |
| ) | |
| foreach ($scriptPath in $scripts) { | |
| Write-Host "Testing: $scriptPath" | |
| $content = Get-Content $scriptPath -Raw | |
| # Verify Action parameter (param block + [string]$Action) | |
| if ($content -notmatch 'param\s*\(' -or $content -notmatch '\[string\]\$Action') { | |
| Write-Error "Missing -Action parameter in $scriptPath" | |
| exit 1 | |
| } | |
| # Verify switch dispatcher | |
| if ($content -notmatch 'switch\s*\(\$Action') { | |
| Write-Error "Missing switch dispatcher in $scriptPath" | |
| exit 1 | |
| } | |
| # Verify NO Show-MainMenu (non-interactive) | |
| if ($content -match 'function\s+Show-MainMenu') { | |
| Write-Error "Script should not have Show-MainMenu (non-interactive): $scriptPath" | |
| exit 1 | |
| } | |
| # Verify NO Read-Host (non-interactive) — (?m) makes ^ match start of each line | |
| if ($content -match '(?m)^\s*(?:\$\w+\s*=\s*)?Read-Host') { | |
| Write-Error "Script should be non-interactive (no Read-Host): $scriptPath" | |
| exit 1 | |
| } | |
| Write-Host "[+] $scriptPath validated" | |
| } | |
| Write-Host "[+] All scripts have correct non-interactive architecture" | |
| # Scan every script separately since revert scripts use a different dispatch pattern. | |
| $allPs1 = Get-ChildItem -Path "scripts" -Filter "*.ps1" -Recurse | | |
| Where-Object { $_.Name -ne 'CommonFunctions.ps1' } | |
| foreach ($f in $allPs1) { | |
| if ((Get-Content $f.FullName -Raw) -match '(?m)^\s*(?:\$\w+\s*=\s*)?Read-Host') { | |
| Write-Error "Interactive Read-Host found (scripts must be non-interactive): $($f.Name)" | |
| exit 1 | |
| } | |
| } | |
| Write-Host "[+] No interactive Read-Host in any script (CommonFunctions is PTW_EMBEDDED-guarded)" | |
| shell: pwsh | |
| - name: Verify protected download destinations | |
| run: | | |
| $violations = Get-ChildItem -Path "scripts" -Filter "*.ps1" -Recurse | | |
| Select-String -Pattern 'Get-FileFromWeb.*\$env:(?:TEMP|TMP)' | |
| if ($violations) { | |
| $violations | ForEach-Object { | |
| Write-Error "$($_.Path):$($_.LineNumber): privileged download uses a user-writable temp path" | |
| } | |
| exit 1 | |
| } | |
| Write-Host "[+] Privileged downloads use the ACL-protected runtime directory" | |
| shell: pwsh | |
| - name: Verify C# action IDs match PowerShell cases | |
| run: ./tools/Test-ActionRouting.ps1 | |
| shell: pwsh | |
| - name: Test create_restore_point script | |
| run: | | |
| $scriptPath = "scripts/create_restore_point.ps1" | |
| Write-Host "Testing create_restore_point script..." | |
| if (-not (Test-Path $scriptPath)) { | |
| Write-Error "Script not found: $scriptPath" | |
| exit 1 | |
| } | |
| $scriptContent = Get-Content $scriptPath -Raw | |
| # Verify it has param block | |
| if ($scriptContent -notmatch 'param\s*\(') { | |
| Write-Error "Missing param block" | |
| exit 1 | |
| } | |
| # Verify it uses Checkpoint-Computer or similar | |
| if ($scriptContent -notmatch 'Checkpoint-Computer|RestorePoint') { | |
| Write-Error "Missing restore point creation logic" | |
| exit 1 | |
| } | |
| # Syntax validation | |
| $parseErrors = $null | |
| [void][System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$null, [ref]$parseErrors) | |
| if ($parseErrors.Count -gt 0) { | |
| Write-Error "Syntax errors in $scriptPath" | |
| exit 1 | |
| } | |
| Write-Host "[+] create_restore_point script validated" | |
| shell: pwsh | |
| - name: Test revert scripts structure | |
| run: | | |
| Write-Host "Testing revert scripts structure..." | |
| $revertScripts = @( | |
| "scripts/Privacy Security/revert-privacy.ps1", | |
| "scripts/Defender/revert-defender.ps1", | |
| "scripts/Exploit Protection/revert-exploit-protection.ps1", | |
| "scripts/Device Guard/revert-device-guard.ps1", | |
| "scripts/Network Security/revert-network-security.ps1", | |
| "scripts/System Security/revert-system-security.ps1" | |
| ) | |
| foreach ($scriptPath in $revertScripts) { | |
| Write-Host "Testing: $scriptPath" | |
| $content = Get-Content $scriptPath -Raw | |
| # Verify Mode parameter | |
| if ($content -notmatch '\[string\]\$Mode') { | |
| Write-Error "Missing Mode parameter in $scriptPath" | |
| exit 1 | |
| } | |
| # Verify PTW_EMBEDDED handling (directly or via CommonFunctions.ps1) | |
| if ($content -notmatch 'PTW_EMBEDDED' -and $content -notmatch 'CommonFunctions\.ps1') { | |
| Write-Error "Missing PTW_EMBEDDED handling in $scriptPath" | |
| exit 1 | |
| } | |
| # Verify admin check | |
| if ($content -notmatch '#Requires -RunAsAdministrator') { | |
| Write-Error "Missing admin requirement in $scriptPath" | |
| exit 1 | |
| } | |
| # Syntax check | |
| $parseErrors = $null | |
| [void][System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$null, [ref]$parseErrors) | |
| if ($parseErrors.Count -gt 0) { | |
| Write-Error "Syntax errors in $scriptPath" | |
| exit 1 | |
| } | |
| Write-Host "[+] $scriptPath validated" | |
| } | |
| Write-Host "[+] All revert scripts have correct structure" | |
| shell: pwsh | |
| build-exe: | |
| name: Build Windows EXE | |
| runs-on: windows-latest | |
| needs: [validate-scripts, functional-testing] | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Harden Runner | |
| uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2 | |
| with: | |
| egress-policy: audit | |
| - name: Checkout repository | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Setup .NET 10 | |
| uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 | |
| with: | |
| dotnet-version: '10.0.x' | |
| - name: Cache NuGet packages | |
| uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 | |
| with: | |
| path: ~/.nuget/packages | |
| key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} | |
| restore-keys: ${{ runner.os }}-nuget- | |
| - name: Restore dependencies | |
| # --locked-mode fails the build if packages.lock.json is out of date. | |
| run: dotnet restore PleaseTweakWindows.sln --locked-mode | |
| - name: Run tests | |
| run: dotnet test PleaseTweakWindows.sln -c Release --no-restore --verbosity normal -p:TreatWarningsAsErrors=true --collect:"XPlat Code Coverage" --results-directory TestResults | |
| - name: Upload test coverage | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: test-coverage | |
| path: TestResults/**/coverage.cobertura.xml | |
| if-no-files-found: error | |
| - name: Enforce test coverage floor | |
| run: | | |
| $reports = @(Get-ChildItem "TestResults" -Recurse -Filter "coverage.cobertura.xml") | |
| # Fail on zero or multiple reports so a second test project can't hide low coverage. | |
| if ($reports.Count -ne 1) { | |
| Write-Error "Expected exactly one coverage report, found $($reports.Count)." | |
| exit 1 | |
| } | |
| $coverageFile = $reports[0] | |
| [xml]$coverage = Get-Content $coverageFile.FullName | |
| $lineRate = [double]::Parse( | |
| $coverage.coverage.'line-rate', | |
| [Globalization.CultureInfo]::InvariantCulture) | |
| $minimum = 0.25 | |
| Write-Host ("Line coverage: {0:P2} (minimum {1:P0})" -f $lineRate, $minimum) | |
| if ($lineRate -lt $minimum) { | |
| Write-Error ("Line coverage {0:P2} is below the {1:P0} floor." -f $lineRate, $minimum) | |
| exit 1 | |
| } | |
| shell: pwsh | |
| - name: Publish single-file EXE | |
| run: | | |
| dotnet publish src/PleaseTweakWindows/PleaseTweakWindows.csproj ` | |
| -c Release ` | |
| -r win-x64 ` | |
| --self-contained true ` | |
| -p:PublishSingleFile=true ` | |
| -p:IncludeNativeLibrariesForSelfExtract=true ` | |
| --no-restore ` | |
| -o publish | |
| shell: pwsh | |
| - name: Verify EXE exists | |
| run: | | |
| $exe = "publish/PleaseTweakWindows.exe" | |
| if (Test-Path $exe) { | |
| Write-Host "[+] EXE file found at $exe" | |
| $fileInfo = Get-Item $exe | |
| Write-Host "File size: $([math]::Round($fileInfo.Length / 1MB, 1)) MB ($($fileInfo.Length) bytes)" | |
| } else { | |
| Write-Error "[-] EXE file not found at $exe" | |
| exit 1 | |
| } | |
| shell: pwsh | |
| - name: Generate SBOM (CycloneDX) | |
| run: | | |
| # Written next to the EXE so the dependency inventory travels in the same artifact. | |
| dotnet tool install --global CycloneDX --version 6.2.0 | |
| $env:PATH += ";$env:USERPROFILE\.dotnet\tools" | |
| # CycloneDX 6.x: JSON output is --json (the old -j short flag was removed). | |
| dotnet CycloneDX src/PleaseTweakWindows/PleaseTweakWindows.csproj -o publish --json | |
| if (-not (Test-Path "publish/bom.json")) { | |
| Write-Error "SBOM generation did not produce publish/bom.json" | |
| exit 1 | |
| } | |
| Write-Host "[+] SBOM generated: publish/bom.json" | |
| shell: pwsh | |
| - name: Collect redistributed dependency licenses | |
| run: | | |
| $licenseRoot = "publish/licenses" | |
| New-Item -ItemType Directory -Path $licenseRoot -Force | Out-Null | |
| Copy-Item "licenses/Apache-2.0.txt" (Join-Path $licenseRoot "Apache-2.0.txt") | |
| $dotnetRoot = Split-Path (Get-Command dotnet).Source | |
| foreach ($name in @("LICENSE.txt", "ThirdPartyNotices.txt")) { | |
| $source = Join-Path $dotnetRoot $name | |
| if (-not (Test-Path $source)) { | |
| Write-Error "Required .NET redistribution notice not found: $source" | |
| exit 1 | |
| } | |
| Copy-Item $source (Join-Path $licenseRoot "dotnet-$name") | |
| } | |
| $packageIds = @( | |
| "communitytoolkit.mvvm", | |
| "microsoft.extensions.dependencyinjection", | |
| "microsoft.extensions.dependencyinjection.abstractions", | |
| "microsoft.extensions.logging", | |
| "microsoft.extensions.logging.abstractions", | |
| "microsoft.extensions.options", | |
| "microsoft.extensions.primitives" | |
| ) | |
| foreach ($id in $packageIds) { | |
| $packageRoot = Join-Path $env:USERPROFILE ".nuget\packages\$id" | |
| $resolvedPackage = Get-ChildItem $packageRoot -Directory -ErrorAction SilentlyContinue | | |
| Sort-Object { [version]$_.Name } -Descending | | |
| Select-Object -First 1 | |
| if (-not $resolvedPackage) { | |
| Write-Error "Restored package directory not found for $id" | |
| exit 1 | |
| } | |
| $licenseFiles = Get-ChildItem $resolvedPackage.FullName -File -ErrorAction SilentlyContinue | | |
| Where-Object { $_.Name -match '^(license|notice|third-party)' } | |
| if (-not $licenseFiles) { | |
| Write-Error "No license/notice file found for redistributed package $id" | |
| exit 1 | |
| } | |
| $target = Join-Path $licenseRoot $id | |
| New-Item -ItemType Directory -Path $target -Force | Out-Null | |
| foreach ($file in $licenseFiles) { | |
| Copy-Item $file.FullName (Join-Path $target $file.Name) -Force | |
| } | |
| } | |
| Write-Host "[+] Redistribution licenses collected" | |
| shell: pwsh | |
| - name: Verify scripts are included in resources | |
| run: | | |
| $requiredScripts = @( | |
| "Gaming optimizations/Gaming-Optimizations.ps1", | |
| "Network optimizations/Network-Optimizations.ps1", | |
| "Performance/performance.ps1", | |
| "Privacy Security/privacy.ps1", | |
| "Privacy Security/revert-privacy.ps1", | |
| "Defender/defender.ps1", | |
| "Defender/revert-defender.ps1", | |
| "Exploit Protection/exploit-protection.ps1", | |
| "Exploit Protection/revert-exploit-protection.ps1", | |
| "Device Guard/device-guard.ps1", | |
| "Device Guard/revert-device-guard.ps1", | |
| "Network Security/network-security.ps1", | |
| "Network Security/revert-network-security.ps1", | |
| "System Security/system-security.ps1", | |
| "System Security/revert-system-security.ps1", | |
| "Debloat/debloat.ps1", | |
| "Maintenance/maintenance.ps1", | |
| "create_restore_point.ps1" | |
| ) | |
| $requiredRegFiles = @( | |
| "Performance/regs/Registry-Optimize.reg", | |
| "Performance/regs/Registry-Defaults.reg", | |
| "Debloat/regs/servicesTweaked.reg", | |
| "Debloat/regs/servicesDefault.reg" | |
| ) | |
| $scriptsDir = "scripts" | |
| foreach ($script in $requiredScripts) { | |
| $fullPath = Join-Path $scriptsDir $script | |
| if (-not (Test-Path $fullPath)) { | |
| Write-Error "Required script not found: $fullPath" | |
| exit 1 | |
| } | |
| Write-Host "Verified: $script" | |
| } | |
| foreach ($regFile in $requiredRegFiles) { | |
| $fullPath = Join-Path $scriptsDir $regFile | |
| if (-not (Test-Path $fullPath)) { | |
| Write-Error "Required reg file not found: $fullPath" | |
| exit 1 | |
| } | |
| Write-Host "Verified: $regFile" | |
| } | |
| Write-Host "All required scripts and reg files are present" | |
| shell: pwsh | |
| - name: Verify scripts embedded in assembly | |
| run: | | |
| # Load the main DLL (not the single-file wrapper) to inspect embedded resources. | |
| $dll = Get-ChildItem -Path "src/PleaseTweakWindows/bin/Release" -Filter "PleaseTweakWindows.dll" -Recurse | | |
| Where-Object { $_.FullName -notmatch 'publish' } | | |
| Select-Object -First 1 | |
| if (-not $dll) { | |
| Write-Error "PleaseTweakWindows.dll not found for resource inspection" | |
| exit 1 | |
| } | |
| $bytes = [IO.File]::ReadAllBytes($dll.FullName) | |
| $asm = [Reflection.Assembly]::Load($bytes) | |
| $scriptResources = $asm.GetManifestResourceNames() | Where-Object { $_ -like '*Scripts*' } | |
| Write-Host "Embedded script resources: $($scriptResources.Count)" | |
| # Inventory: 22 .ps1 + index.txt + file-checksums.json + 4 .reg + nvidia_profile.xml + ooshutup10.cfg = 30. | |
| if ($scriptResources.Count -lt 30) { | |
| Write-Error "Expected at least 30 embedded script resources, found $($scriptResources.Count)" | |
| exit 1 | |
| } | |
| Write-Host "[+] Scripts are embedded in the assembly" | |
| shell: pwsh | |
| - name: Upload EXE artifact | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: please-tweak-windows-exe | |
| # Both paths live under publish/ so the artifact root stays flat. | |
| path: | | |
| publish/PleaseTweakWindows.exe | |
| publish/bom.json | |
| publish/licenses | |
| create-release: | |
| name: Create Release | |
| runs-on: windows-latest | |
| needs: build-exe | |
| if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' | |
| environment: release | |
| # Never overlap or cancel a release while assets are being published. | |
| concurrency: | |
| group: release | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Harden Runner | |
| uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2 | |
| with: | |
| egress-policy: audit | |
| - name: Checkout repository | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| fetch-depth: 0 | |
| - name: Determine release version | |
| id: version | |
| run: | | |
| # The release tag must match <Version> in the main csproj. | |
| $csprojPath = 'src/PleaseTweakWindows/PleaseTweakWindows.csproj' | |
| $xml = [xml](Get-Content $csprojPath) | |
| $version = $xml.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 | |
| if (-not $version) { | |
| Write-Error "No <Version> element found in $csprojPath — cannot determine release tag." | |
| exit 1 | |
| } | |
| $tag = "v$version" | |
| Write-Host "csproj <Version>: $version → tag: $tag" | |
| if ($env:GITHUB_REF_NAME -ne $tag) { | |
| Write-Error "Pushed tag '$env:GITHUB_REF_NAME' does not match csproj version '$tag'." | |
| exit 1 | |
| } | |
| git fetch origin main --no-tags | |
| git merge-base --is-ancestor $env:GITHUB_SHA origin/main | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Error "Release tag must point to a commit contained in protected main." | |
| exit 1 | |
| } | |
| "release=true" | Out-File -Append $env:GITHUB_OUTPUT | |
| "version=$tag" | Out-File -Append $env:GITHUB_OUTPUT | |
| shell: pwsh | |
| - name: Download EXE artifact | |
| if: steps.version.outputs.release == 'true' | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: please-tweak-windows-exe | |
| path: ./artifacts | |
| - name: Sign release EXE (Authenticode) | |
| if: steps.version.outputs.release == 'true' | |
| run: | | |
| if ([string]::IsNullOrWhiteSpace($env:CODE_SIGN_PFX_BASE64) -or | |
| [string]::IsNullOrWhiteSpace($env:CODE_SIGN_PASSWORD)) { | |
| Write-Error "The protected release environment must provide CODE_SIGN_PFX_BASE64 and CODE_SIGN_PASSWORD." | |
| exit 1 | |
| } | |
| $pfx = Join-Path $env:RUNNER_TEMP 'ptw-codesign.pfx' | |
| try { | |
| [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:CODE_SIGN_PFX_BASE64)) | |
| $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe | | |
| Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -Last 1 | |
| if (-not $signtool) { throw "signtool.exe not found on the runner" } | |
| & $signtool.FullName sign /f $pfx /p $env:CODE_SIGN_PASSWORD /fd SHA256 ` | |
| /tr http://timestamp.digicert.com /td SHA256 "./artifacts/PleaseTweakWindows.exe" | |
| if ($LASTEXITCODE -ne 0) { throw "signtool failed (exit $LASTEXITCODE)" } | |
| } | |
| finally { | |
| Remove-Item $pfx -Force -ErrorAction SilentlyContinue | |
| } | |
| env: | |
| CODE_SIGN_PFX_BASE64: ${{ secrets.CODE_SIGN_PFX_BASE64 }} | |
| CODE_SIGN_PASSWORD: ${{ secrets.CODE_SIGN_PASSWORD }} | |
| shell: pwsh | |
| - name: Verify release signature | |
| if: steps.version.outputs.release == 'true' | |
| run: | | |
| $signature = Get-AuthenticodeSignature "./artifacts/PleaseTweakWindows.exe" | |
| if ($signature.Status -ne 'Valid') { | |
| Write-Error "Release EXE is not validly Authenticode-signed: $($signature.StatusMessage)" | |
| exit 1 | |
| } | |
| Write-Host "[+] Valid release signature: $($signature.SignerCertificate.Subject)" | |
| shell: pwsh | |
| - name: Create distribution package | |
| if: steps.version.outputs.release == 'true' | |
| run: | | |
| if (Test-Path "dist") { Remove-Item -Recurse -Force "dist" } | |
| New-Item -ItemType Directory -Path "dist" | Out-Null | |
| Copy-Item "./artifacts/PleaseTweakWindows.exe" "dist/" | |
| Copy-Item "README.md" "dist/" | |
| if (Test-Path "LICENSE") { | |
| Copy-Item "LICENSE" "dist/" | |
| } else { | |
| Write-Error "LICENSE file not found — refusing to publish" | |
| exit 1 | |
| } | |
| if (Test-Path "THIRD-PARTY-NOTICES.md") { | |
| Copy-Item "THIRD-PARTY-NOTICES.md" "dist/" | |
| } else { | |
| Write-Error "THIRD-PARTY-NOTICES.md not found — refusing to publish" | |
| exit 1 | |
| } | |
| if (Test-Path "./artifacts/bom.json") { | |
| Copy-Item "./artifacts/bom.json" "dist/SBOM.json" | |
| } else { | |
| # The README promises an SBOM with every release, so refuse to publish without one. | |
| Write-Error "SBOM (bom.json) not found in build artifact — refusing to publish a release without the promised SBOM" | |
| exit 1 | |
| } | |
| if (Test-Path "./artifacts/licenses") { | |
| Copy-Item "./artifacts/licenses" "dist/licenses" -Recurse | |
| } else { | |
| Write-Error "Dependency license bundle not found — refusing to publish" | |
| exit 1 | |
| } | |
| # The EXE embeds all scripts; no loose scripts folder is shipped. | |
| # Create ZIP file using PowerShell | |
| Compress-Archive -Path "dist/*" -DestinationPath "PleaseTweakWindows.zip" -Force | |
| # Verify ZIP was created | |
| if (Test-Path "PleaseTweakWindows.zip") { | |
| $zipInfo = Get-Item "PleaseTweakWindows.zip" | |
| Write-Host "Distribution package created: $($zipInfo.Length) bytes" | |
| } else { | |
| Write-Error "Failed to create distribution package" | |
| exit 1 | |
| } | |
| shell: pwsh | |
| - name: Generate SHA256SUMS | |
| if: steps.version.outputs.release == 'true' | |
| run: | | |
| # Publish an archive checksum in addition to the EXE's Authenticode signature. | |
| $h = (Get-FileHash -Algorithm SHA256 -Path "PleaseTweakWindows.zip").Hash | |
| "$h PleaseTweakWindows.zip" | Out-File -Encoding ascii -FilePath "SHA256SUMS.txt" | |
| Get-Content "SHA256SUMS.txt" | |
| shell: pwsh | |
| - name: Create Release | |
| if: steps.version.outputs.release == 'true' | |
| uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v2 | |
| with: | |
| tag_name: ${{ steps.version.outputs.version }} | |
| name: PleaseTweakWindows ${{ steps.version.outputs.version }} | |
| files: | | |
| PleaseTweakWindows.zip | |
| SHA256SUMS.txt | |
| dist/SBOM.json | |
| draft: false | |
| prerelease: false | |
| generate_release_notes: true | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |