From d69530c410fd796fd4e3dfc991a72668dd194494 Mon Sep 17 00:00:00 2001 From: stuartd Date: Wed, 16 Sep 2026 15:31:37 +0100 Subject: [PATCH] Publish a smaller .NET 10 release variant --- .github/workflows/release.yml | 5 +- .github/workflows/windows-build.yml | 14 ++- README.md | 46 +++++--- RELEASING.md | 34 ++++-- SPEC.md | 9 +- scripts/create-local-release.ps1 | 156 +++++++++++++++++----------- 6 files changed, 166 insertions(+), 98 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30d3404..657238a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,5 +47,6 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} run: | - $archive = "releases/ClipDiff-$env:RELEASE_VERSION-win-x64.zip" - gh release create $env:RELEASE_TAG $archive --verify-tag --generate-notes --title "ClipDiff $env:RELEASE_TAG" + $selfContained = "releases/ClipDiff-$env:RELEASE_VERSION-win-x64-self-contained.zip" + $net10 = "releases/ClipDiff-$env:RELEASE_VERSION-win-x64-net10.zip" + gh release create $env:RELEASE_TAG $selfContained $net10 --verify-tag --generate-notes --title "ClipDiff $env:RELEASE_TAG" diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 95682ca..9ce33dd 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -34,10 +34,18 @@ jobs: shell: pwsh run: ./scripts/create-local-release.ps1 - - name: Upload portable Windows build + - name: Upload self-contained Windows build uses: actions/upload-artifact@v7 with: - name: ClipDiff-win-x64 - path: releases/ClipDiff-*-win-x64/ + name: ClipDiff-win-x64-self-contained + path: releases/ClipDiff-*-win-x64-self-contained/ + if-no-files-found: error + retention-days: 14 + + - name: Upload .NET 10 Windows build + uses: actions/upload-artifact@v7 + with: + name: ClipDiff-win-x64-net10 + path: releases/ClipDiff-*-win-x64-net10/ if-no-files-found: error retention-days: 14 diff --git a/README.md b/README.md index 30527e4..1935228 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,18 @@ Windows Server Core is not supported. The initial release target is `win-x64`. ## Install or update -Download the `ClipDiff--win-x64.zip` asset from the GitHub release and -extract the whole archive into its own directory. The complete application is -exactly these two files: +Download one of these assets from the GitHub release and extract the whole +archive into its own directory: + +- `ClipDiff--win-x64-self-contained.zip` includes its own .NET runtime + and works without a separately installed runtime. It is the larger download. +- `ClipDiff--win-x64-net10.zip` is much smaller but requires the x64 + **.NET 10 Desktop Runtime** (`Microsoft.WindowsDesktop.App`), not merely the + base `Microsoft.NETCore.App` runtime. + +Check an existing installation with `dotnet --list-runtimes`; the smaller build +requires a `Microsoft.WindowsDesktop.App 10.x` entry for x64. Both archives +contain exactly these two files: ```text ClipDiff.exe @@ -189,10 +198,11 @@ An older test launcher could print `elevated=0, elevationType=2` after dropping If the native test run fails, include the full output from `Native build:` through the final `[FAIL]` line and script error. The output identifies the test case, expected visibility, direct-handler and Windows-aggregate results, HRESULT/Win32 errors, selection counts and Shell attributes, and effective COM/menu registration. A visibility mismatch also prints menu IDs, states, separator labels, and a read-only snapshot of Shell restriction settings and ClipDiff's Approved/Blocked entries. These probes do not change policy or read clipboard contents or selected file paths. Passing the direct-handler check but failing the Windows-aggregate check narrows the problem to Shell discovery/aggregation; it does not by itself establish that a work-machine policy is responsible. Microsoft's [Shell extension approval policy documentation](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-windowsexplorer#enforceshellextensionsecurity) explains one possible restriction. GitHub Actions runs the same release script on `windows-latest` for every push -and pull request. Each successful run provides a `ClipDiff-win-x64` artifact -containing only the executable and native DLL; extract both into the same -directory. It can be run manually from the repository's **Actions** tab as well. -Actual Explorer desktop interaction remains a separate manual check. +and pull request. Each successful run provides `ClipDiff-win-x64-self-contained` +and `ClipDiff-win-x64-net10` artifacts. Each contains only the executable and +native DLL; extract both into the same directory. The workflow can be run +manually from the repository's **Actions** tab as well. Actual Explorer desktop +interaction remains a separate manual check. ## Local release @@ -208,16 +218,20 @@ output in a clean private directory, then creates both of these gitignored outputs: ```text -releases\ClipDiff--win-x64\ -releases\ClipDiff--win-x64.zip +releases\ClipDiff--win-x64-self-contained\ +releases\ClipDiff--win-x64-self-contained.zip +releases\ClipDiff--win-x64-net10\ +releases\ClipDiff--win-x64-net10.zip ``` -The directory and ZIP are validated to contain exactly `ClipDiff.exe` and +Both directories and ZIPs are validated to contain exactly `ClipDiff.exe` and `ClipDiff.ShellExtension.dll`. Those are the complete release; keep them -together. PDB, `deps.json`, `runtimeconfig.json`, `bin`, `obj`, and the rest of -the raw publish directory are not release payload. Pass `-Launch` to start the -packaged executable after publishing. The personal build is unsigned, so -Windows SmartScreen may warn before first launch. +together. The self-contained variant bundles .NET; the `net10` variant expects +the x64 .NET 10 Desktop Runtime on the target computer. PDB, `deps.json`, +`runtimeconfig.json`, `bin`, `obj`, and the rest of the raw publish directories +are not release payload. Pass `-Launch` to start the self-contained packaged +executable after publishing. The personal builds are unsigned, so Windows +SmartScreen may warn before first launch. To skip the native Explorer integration tests for a local release, pass `-SkipNativeTests`: @@ -241,8 +255,8 @@ Set-ExecutionPolicy -Scope Process Bypass .\scripts\create-local-release.ps1 ``` -Copy the resulting ZIP to the target PC and extract both files together. No -separate .NET runtime is required. +Copy the chosen ZIP to the target PC and extract both files together. Choose the +`net10` archive only when the x64 .NET 10 Desktop Runtime is already installed. No installer, automatic updater, or code signing is included in the initial release. diff --git a/RELEASING.md b/RELEASING.md index 26e552c..fcb58cf 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,7 +1,7 @@ # Releasing ClipDiff -The release contract is the ZIP, not a `bin` or raw `dotnet publish` directory. -Every ClipDiff Windows x64 release contains exactly: +The release contract is the ZIPs, not a `bin` or raw `dotnet publish` +directory. Every ClipDiff Windows x64 package contains exactly: ```text ClipDiff.exe @@ -23,15 +23,25 @@ On Windows, from a normal non-administrator PowerShell in the repository root: The script requires the .NET 10 SDK and Visual Studio C++ build tools with a Windows SDK. It builds and tests the native extension, runs the .NET tests, publishes the self-contained application in a clean staging directory, and -validates the final folder and ZIP. Output is: +validates both final folders and ZIPs. Output is: ```text -releases\ClipDiff--win-x64\ +releases\ClipDiff--win-x64-self-contained\ ClipDiff.exe ClipDiff.ShellExtension.dll -releases\ClipDiff--win-x64.zip +releases\ClipDiff--win-x64-self-contained.zip + +releases\ClipDiff--win-x64-net10\ + ClipDiff.exe + ClipDiff.ShellExtension.dll +releases\ClipDiff--win-x64-net10.zip ``` +The self-contained package bundles .NET. The smaller `net10` package requires +the x64 .NET 10 Desktop Runtime and checks for `Microsoft.WindowsDesktop.App +10.x` when documenting compatibility; the base .NET runtime alone is not +sufficient for this WPF application. + The version defaults to the `Version` property in `Directory.Build.props`. A specific version can be supplied when reproducing a tagged build: @@ -55,15 +65,17 @@ a published release. ``` The **Release** workflow validates the tag, runs the complete release script, -and creates a GitHub release with generated notes and -`ClipDiff-1.2.3-win-x64.zip` attached. It will not publish if any build, test, or -payload validation fails. +and creates a GitHub release with generated notes and both +`ClipDiff-1.2.3-win-x64-self-contained.zip` and +`ClipDiff-1.2.3-win-x64-net10.zip` attached. It will not publish if any build, +test, or payload validation fails. ## Install or update the portable build -Extract the whole ZIP into a directory owned by the current user and run -`ClipDiff.exe`. Do not move the DLL elsewhere or run `regsvr32`; ClipDiff owns -its per-user Explorer registration. +Extract one whole ZIP into a directory owned by the current user and run +`ClipDiff.exe`. Use the `net10` ZIP only when `dotnet --list-runtimes` contains +an x64 `Microsoft.WindowsDesktop.App 10.x` entry. Do not move the DLL elsewhere +or run `regsvr32`; ClipDiff owns its per-user Explorer registration. For an update, quit ClipDiff and extract the new version into a new directory. This avoids overwriting a DLL that Explorer may still have loaded. Update any diff --git a/SPEC.md b/SPEC.md index 6fcd510..6fbada7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1318,9 +1318,10 @@ It should: 1. Build and test the native Explorer DLL with Visual Studio C++ build tools and the Windows SDK. 1. Run core tests. 1. Publish a Release build through a clean private staging directory. -1. Place a versioned directory and ZIP in the gitignored `releases/` directory. -1. Place `ClipDiff.ShellExtension.dll` alongside `ClipDiff.exe`; keep the DLL outside the .NET single-file bundle. -1. Validate that the versioned directory and ZIP contain exactly `ClipDiff.exe` and `ClipDiff.ShellExtension.dll`, with no SDK by-products or stale files. +1. Place versioned self-contained and .NET 10 framework-dependent directories and ZIPs in the gitignored `releases/` directory. +1. Publish both variants as single-file applications. The framework-dependent variant requires the x64 .NET 10 Desktop Runtime. +1. Place `ClipDiff.ShellExtension.dll` alongside `ClipDiff.exe` in both variants; keep the DLL outside the .NET single-file bundle. +1. Validate that every versioned directory and ZIP contains exactly `ClipDiff.exe` and `ClipDiff.ShellExtension.dll`, with no SDK by-products or stale files. 1. Optionally launch the resulting executable. Suggested publish properties: @@ -1337,7 +1338,7 @@ Do not enable trimming for the initial WPF build. The native projects are built separately with `scripts/build-shell-extension.ps1 -Test`, so macOS can still compile and test the .NET solution. The release script runs this native build first. Distribute the executable and DLL together. Explorer can keep a loaded DLL locked after ClipDiff quits; updating into a new directory avoids overwriting a loaded DLL, and restarting Explorer or signing out may be needed to unload an older handler. Do not restart Explorer automatically. -Continuous integration must call the same release script rather than duplicate the publish commands. A semantic-version tag in the form `v1.2.3` should run the complete build and test process, then create a GitHub release containing the validated versioned ZIP. +Continuous integration must call the same release script rather than duplicate the publish commands. A semantic-version tag in the form `v1.2.3` should run the complete build and test process, then create a GitHub release containing both validated versioned ZIPs. For local releases, `create-local-release.ps1 -SkipNativeTests` may explicitly skip the native Explorer integration tests. It must still build and package the DLL and run the .NET tests. Native tests remain enabled by default and in CI. diff --git a/scripts/create-local-release.ps1 b/scripts/create-local-release.ps1 index d8838d4..24cee9f 100644 --- a/scripts/create-local-release.ps1 +++ b/scripts/create-local-release.ps1 @@ -8,7 +8,7 @@ param( $ErrorActionPreference = 'Stop' $repositoryRoot = Split-Path -Parent $PSScriptRoot $releaseRoot = Join-Path $repositoryRoot 'releases' -$publishDirectory = Join-Path $repositoryRoot 'artifacts/release-publish/win-x64' +$publishRoot = Join-Path $repositoryRoot 'artifacts/release-publish' $packageStagingRoot = Join-Path $repositoryRoot 'artifacts/release-package' $applicationProject = Join-Path $repositoryRoot 'src/ClipDiff.Windows/ClipDiff.Windows.csproj' $coreTests = Join-Path $repositoryRoot 'tests/ClipDiff.Core.Tests/ClipDiff.Core.Tests.csproj' @@ -16,6 +16,14 @@ $privacyTests = Join-Path $repositoryRoot 'tests/ClipDiff.Windows.Tests/ClipDiff $nativeDll = Join-Path $repositoryRoot 'artifacts/native/Release/ClipDiff.ShellExtension.dll' $requiredPayload = @('ClipDiff.exe', 'ClipDiff.ShellExtension.dll') +function Assert-ReleasePayload([string]$Directory, [string]$Description) { + $actualPayload = @(Get-ChildItem $Directory -File | Select-Object -ExpandProperty Name | Sort-Object) + $unexpectedPayload = @(Compare-Object ($requiredPayload | Sort-Object) $actualPayload) + if ($unexpectedPayload.Count -ne 0) { + throw "$Description validation failed. Expected only: $($requiredPayload -join ', ')." + } +} + if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { throw 'The .NET 10 SDK is required. Install it from https://dotnet.microsoft.com/download/dotnet/10.0 and open a new PowerShell window.' } @@ -37,19 +45,28 @@ try { throw "Release version '$Version' is invalid. Use a version such as 1.0.0 or 1.0.0-preview.1." } - $packageName = "ClipDiff-$Version-win-x64" - $packageDirectory = Join-Path $releaseRoot $packageName - $archivePath = Join-Path $releaseRoot "$packageName.zip" - $packageStagingDirectory = Join-Path $packageStagingRoot $packageName - $archiveStagingPath = Join-Path $packageStagingRoot "$packageName.zip" - - # dotnet publish does not clean a custom output directory. Always publish to a - # private, empty staging directory so stale files can never enter a release. - if (Test-Path $publishDirectory) { Remove-Item $publishDirectory -Recurse -Force } - if (Test-Path $packageStagingDirectory) { Remove-Item $packageStagingDirectory -Recurse -Force } - if (Test-Path $archiveStagingPath) { Remove-Item $archiveStagingPath -Force } - New-Item $publishDirectory -ItemType Directory -Force | Out-Null - New-Item $packageStagingDirectory -ItemType Directory -Force | Out-Null + $packages = @( + [pscustomobject]@{ + Name = "ClipDiff-$Version-win-x64-self-contained" + SelfContained = $true + Requirement = 'No separately installed .NET runtime required' + PublishDirectory = $null + StagingDirectory = $null + StagingArchive = $null + ReleaseDirectory = $null + ReleaseArchive = $null + }, + [pscustomobject]@{ + Name = "ClipDiff-$Version-win-x64-net10" + SelfContained = $false + Requirement = 'Requires the x64 .NET 10 Desktop Runtime' + PublishDirectory = $null + StagingDirectory = $null + StagingArchive = $null + ReleaseDirectory = $null + ReleaseArchive = $null + } + ) if ($SkipNativeTests) { Write-Host 'Skipping native Explorer integration tests; the extension DLL will still be built.' @@ -62,62 +79,77 @@ try { dotnet test $privacyTests --configuration Release if ($LASTEXITCODE -ne 0) { throw 'Privacy inspector tests failed.' } - dotnet publish $applicationProject ` - --configuration Release ` - --runtime win-x64 ` - --self-contained true ` - --output $publishDirectory ` - -p:Version=$Version ` - -p:PublishSingleFile=true ` - -p:PublishTrimmed=false ` - -p:IncludeNativeLibrariesForSelfExtract=true - if ($LASTEXITCODE -ne 0) { throw 'ClipDiff publish failed.' } - - foreach ($file in $requiredPayload) { - $source = if ($file -eq 'ClipDiff.ShellExtension.dll') { $nativeDll } else { Join-Path $publishDirectory $file } - if (-not (Test-Path $source -PathType Leaf)) { throw "Release payload is missing required file: $file" } - Copy-Item $source $packageStagingDirectory - } - - $actualPayload = @(Get-ChildItem $packageStagingDirectory -File | Select-Object -ExpandProperty Name | Sort-Object) - $unexpectedPayload = @(Compare-Object ($requiredPayload | Sort-Object) $actualPayload) - if ($unexpectedPayload.Count -ne 0) { - throw "Release payload validation failed. Expected only: $($requiredPayload -join ', ')." - } - - Compress-Archive -Path (Join-Path $packageStagingDirectory '*') -DestinationPath $archiveStagingPath -CompressionLevel Optimal + # dotnet publish does not clean custom output directories. Build both variants + # privately and validate both before replacing any public release output. + foreach ($package in $packages) { + $package.PublishDirectory = Join-Path $publishRoot $package.Name + $package.StagingDirectory = Join-Path $packageStagingRoot $package.Name + $package.StagingArchive = Join-Path $packageStagingRoot "$($package.Name).zip" + $package.ReleaseDirectory = Join-Path $releaseRoot $package.Name + $package.ReleaseArchive = Join-Path $releaseRoot "$($package.Name).zip" + + foreach ($path in @($package.PublishDirectory, $package.StagingDirectory)) { + if (Test-Path $path) { Remove-Item $path -Recurse -Force } + New-Item $path -ItemType Directory -Force | Out-Null + } + if (Test-Path $package.StagingArchive) { Remove-Item $package.StagingArchive -Force } + + $selfContained = $package.SelfContained.ToString().ToLowerInvariant() + dotnet publish $applicationProject ` + --configuration Release ` + --runtime win-x64 ` + --self-contained $selfContained ` + --output $package.PublishDirectory ` + -p:Version=$Version ` + -p:PublishSingleFile=true ` + -p:PublishTrimmed=false ` + -p:IncludeNativeLibrariesForSelfExtract=true + if ($LASTEXITCODE -ne 0) { throw "$($package.Name) publish failed." } + + foreach ($file in $requiredPayload) { + $source = if ($file -eq 'ClipDiff.ShellExtension.dll') { $nativeDll } else { Join-Path $package.PublishDirectory $file } + if (-not (Test-Path $source -PathType Leaf)) { throw "$($package.Name) is missing required file: $file" } + Copy-Item $source $package.StagingDirectory + } - Add-Type -AssemblyName System.IO.Compression.FileSystem - $archive = [System.IO.Compression.ZipFile]::OpenRead($archiveStagingPath) - try { - $archivePayload = @($archive.Entries | Where-Object { -not [string]::IsNullOrEmpty($_.Name) } | Select-Object -ExpandProperty FullName | Sort-Object) - $unexpectedArchivePayload = @(Compare-Object ($requiredPayload | Sort-Object) $archivePayload) - if ($unexpectedArchivePayload.Count -ne 0) { - throw "Release archive validation failed. Expected only: $($requiredPayload -join ', ')." + Assert-ReleasePayload $package.StagingDirectory "$($package.Name) directory" + Compress-Archive -Path (Join-Path $package.StagingDirectory '*') -DestinationPath $package.StagingArchive -CompressionLevel Optimal + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($package.StagingArchive) + try { + $archivePayload = @($archive.Entries | Where-Object { -not [string]::IsNullOrEmpty($_.Name) } | Select-Object -ExpandProperty FullName | Sort-Object) + $unexpectedArchivePayload = @(Compare-Object ($requiredPayload | Sort-Object) $archivePayload) + if ($unexpectedArchivePayload.Count -ne 0) { + throw "$($package.Name) archive validation failed. Expected only: $($requiredPayload -join ', ')." + } + } + finally { + $archive.Dispose() } - } - finally { - $archive.Dispose() } - # Replace the public output only after the staged folder and ZIP have both - # passed validation, so a failed build cannot leave a partial release behind. + # Replace public outputs only after both variants pass validation, so a failed + # build cannot publish mismatched or partial packages. New-Item $releaseRoot -ItemType Directory -Force | Out-Null - if (Test-Path $packageDirectory) { Remove-Item $packageDirectory -Recurse -Force } - if (Test-Path $archivePath) { Remove-Item $archivePath -Force } - Move-Item $packageStagingDirectory $packageDirectory - Move-Item $archiveStagingPath $archivePath - Remove-Item $publishDirectory -Recurse -Force - - $executable = Join-Path $packageDirectory 'ClipDiff.exe' - Write-Host '' - Write-Host "Release directory: $packageDirectory" - Write-Host "Release archive: $archivePath" + foreach ($package in $packages) { + if (Test-Path $package.ReleaseDirectory) { Remove-Item $package.ReleaseDirectory -Recurse -Force } + if (Test-Path $package.ReleaseArchive) { Remove-Item $package.ReleaseArchive -Force } + Move-Item $package.StagingDirectory $package.ReleaseDirectory + Move-Item $package.StagingArchive $package.ReleaseArchive + Remove-Item $package.PublishDirectory -Recurse -Force + + Write-Host '' + Write-Host "Release directory: $($package.ReleaseDirectory)" + Write-Host "Release archive: $($package.ReleaseArchive)" + Write-Host "Runtime: $($package.Requirement)" + } + Write-Host "Payload: $($requiredPayload -join ', ')" - Write-Host 'Install: Extract both files into the same directory, then run ClipDiff.exe.' + Write-Host 'Install: Extract both files from one package into the same directory, then run ClipDiff.exe.' if ($Launch) { - Start-Process $executable + Start-Process (Join-Path $packages[0].ReleaseDirectory 'ClipDiff.exe') } } finally {