Skip to content

Bump softprops/action-gh-release from 2.6.1 to 3.0.1 #74

Bump softprops/action-gh-release from 2.6.1 to 3.0.1

Bump softprops/action-gh-release from 2.6.1 to 3.0.1 #74

Workflow file for this run

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 }}