diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..30d3404 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: "true" + DOTNET_NOLOGO: "true" + +jobs: + release: + runs-on: windows-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Set up .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Validate release tag + shell: pwsh + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + $tag = $env:RELEASE_TAG + if ($tag -notmatch '^v(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)$') { + throw "Release tags must look like v1.2.3 or v1.2.3-preview.1; got '$tag'." + } + "RELEASE_VERSION=$($Matches.version)" >> $env:GITHUB_ENV + + - name: Build, test, and package + shell: pwsh + run: ./scripts/create-local-release.ps1 -Version $env:RELEASE_VERSION + + - name: Create GitHub release + shell: pwsh + env: + 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" diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 1d1b47a..95682ca 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -30,34 +30,14 @@ jobs: with: dotnet-version: 10.0.x - - name: Restore - run: dotnet restore ClipDiff.Windows.sln - - - name: Build and test native Explorer extension - shell: pwsh - run: ./scripts/build-shell-extension.ps1 -Configuration Release -Test - - - name: Test - run: dotnet test ClipDiff.Windows.sln --configuration Release --no-restore - - - name: Build - run: dotnet build ClipDiff.Windows.sln --configuration Release --no-restore - - - name: Publish portable Windows build + - name: Build, test, and package shell: pwsh - run: | - dotnet publish src/ClipDiff.Windows/ClipDiff.Windows.csproj --configuration Release --runtime win-x64 --self-contained true --output artifacts/portable -p:PublishSingleFile=true -p:PublishTrimmed=false -p:IncludeNativeLibrariesForSelfExtract=true - if ($LASTEXITCODE -ne 0) { throw 'ClipDiff publish failed.' } - foreach ($file in @('ClipDiff.exe', 'ClipDiff.ShellExtension.dll')) { - if (-not (Test-Path "artifacts/portable/$file" -PathType Leaf)) { throw "Missing portable build file: $file" } - } + run: ./scripts/create-local-release.ps1 - name: Upload portable Windows build uses: actions/upload-artifact@v7 with: name: ClipDiff-win-x64 - path: | - artifacts/portable/ClipDiff.exe - artifacts/portable/ClipDiff.ShellExtension.dll + path: releases/ClipDiff-*-win-x64/ if-no-files-found: error retention-days: 14 diff --git a/Directory.Build.props b/Directory.Build.props index 2d61f30..a71940d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.0 + 1.0.0 latest enable enable diff --git a/README.md b/README.md index 2e614c8..30527e4 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,29 @@ ClipDiff does not guess whether text is sensitive from its length, punctuation, 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: + +```text +ClipDiff.exe +ClipDiff.ShellExtension.dll +``` + +They must stay beside one another. Run `ClipDiff.exe`; the application manages +the Explorer integration per user. Do not copy the DLL to a Windows system +directory or register it manually. + +ClipDiff is portable and has no installer. The directory may be anywhere the +current user can write, for example +`%LOCALAPPDATA%\Programs\ClipDiff\1.0.0`. To update, quit +ClipDiff, extract the new ZIP to a new directory, and run the new executable. +Using a new directory avoids trying to overwrite an older shell-extension DLL +that Explorer may still have loaded. Restarting Explorer or signing out may be +needed before the old directory can be deleted. + ## Build and test ### Prerequisites on Windows @@ -137,11 +160,11 @@ dotnet test ClipDiff.Windows.sln --configuration Release --no-restore dotnet build ClipDiff.Windows.sln --configuration Release --no-restore ``` -The framework-dependent executable is then at +The framework-dependent developer executable is then at `src\ClipDiff.Windows\bin\Release\net10.0-windows10.0.17763.0\ClipDiff.exe`. It requires the .NET 10 Windows Desktop Runtime on the computer where it runs. -For a portable executable that includes its runtime, use the local release script -below instead. +It is not the distributable package. For the self-contained, validated two-file +package, use the local release script below instead. The pure `ClipDiff.Core` project and both policy test assemblies target ordinary `net10.0`, so they can run on macOS: @@ -165,7 +188,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 also builds and tests the native DLL, then tests and builds the .NET Release solution on `windows-latest` for every push and pull request. Each successful run provides a `ClipDiff-win-x64` artifact containing 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. +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. ## Local release @@ -175,7 +202,22 @@ On Windows PowerShell, from the repository root: .\scripts\create-local-release.ps1 ``` -The script builds/tests the native extension and publishes a self-contained, untrimmed `win-x64` application to the gitignored `releases/win-x64` directory. Distribute **both `ClipDiff.exe` and `ClipDiff.ShellExtension.dll`** together; the .NET portion is bundled into the executable. Pass `-Launch` to start it after publishing. The personal build is unsigned, so Windows SmartScreen may warn before first launch. +The script builds and tests the native extension, runs the .NET tests, and +publishes a self-contained, untrimmed `win-x64` application. It stages the SDK +output in a clean private directory, then creates both of these gitignored +outputs: + +```text +releases\ClipDiff--win-x64\ +releases\ClipDiff--win-x64.zip +``` + +The directory and ZIP 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. To skip the native Explorer integration tests for a local release, pass `-SkipNativeTests`: @@ -185,7 +227,11 @@ To skip the native Explorer integration tests for a local release, pass `-SkipNa This still builds and packages the native DLL and runs the .NET tests. Native tests run by default and remain enabled in CI. You can combine this switch with `-Launch`. -Quit ClipDiff before upgrading. Explorer may keep the DLL loaded; use a new release directory if Windows refuses to replace it, and restart Explorer or sign out if an old handler remains loaded. Starting the new version removes the obsolete two-file static menu registration automatically. +Quit ClipDiff before upgrading. The versioned package directory deliberately +makes side-by-side extraction straightforward because Explorer may keep the old +DLL loaded. Restart Explorer or sign out if the old handler remains loaded. +Starting the new version removes the obsolete two-file static menu registration +automatically. If PowerShell reports that script execution is disabled, allow the checked-out script for this process only, then run it again: @@ -195,11 +241,14 @@ Set-ExecutionPolicy -Scope Process Bypass .\scripts\create-local-release.ps1 ``` -The resulting portable executable is `releases\win-x64\ClipDiff.exe`; copy that -file to the work PC and run it without installing a separate .NET runtime. +Copy the resulting ZIP to the target PC and extract both files together. No +separate .NET runtime is required. No installer, automatic updater, or code signing is included in the initial release. +Maintainer instructions for publishing a tagged GitHub release are in +[`RELEASING.md`](RELEASING.md). + ## Windows verification checklist On a Windows desktop, verify: diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..26e552c --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,75 @@ +# Releasing ClipDiff + +The release contract is the ZIP, not a `bin` or raw `dotnet publish` directory. +Every ClipDiff Windows x64 release contains exactly: + +```text +ClipDiff.exe +ClipDiff.ShellExtension.dll +``` + +Both files go in the same directory. The executable is the self-contained .NET +application; the DLL is the native two-file Explorer context-menu handler. Do +not ship either file on its own. + +## Build a release locally + +On Windows, from a normal non-administrator PowerShell in the repository root: + +```powershell +.\scripts\create-local-release.ps1 +``` + +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: + +```text +releases\ClipDiff--win-x64\ + ClipDiff.exe + ClipDiff.ShellExtension.dll +releases\ClipDiff--win-x64.zip +``` + +The version defaults to the `Version` property in `Directory.Build.props`. A +specific version can be supplied when reproducing a tagged build: + +```powershell +.\scripts\create-local-release.ps1 -Version 1.2.3 +``` + +`-SkipNativeTests` skips only the native Shell integration tests; it still +builds and packages the DLL and runs the .NET tests. Do not use that switch for +a published release. + +## Publish on GitHub + +1. Merge the release changes into `main` and confirm **Windows build** succeeds. +2. Choose a semantic version such as `1.2.3`. +3. Create and push an annotated tag for that exact `main` commit: + + ```powershell + git tag -a v1.2.3 -m "ClipDiff v1.2.3" + git push origin v1.2.3 + ``` + +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. + +## 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. + +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 +startup shortcut to the new executable. The old directory can be removed after +Explorer releases its DLL, which may require restarting Explorer or signing +out. + +Release binaries are currently unsigned, so Windows SmartScreen may warn on the +first launch. diff --git a/SPEC.md b/SPEC.md index 511abe2..6fcd510 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1317,9 +1317,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. -1. Place output in a gitignored `releases/` directory. +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. Optionally launch the resulting executable. Suggested publish properties: @@ -1336,6 +1337,8 @@ 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. + 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. A typical command is: diff --git a/scripts/create-local-release.ps1 b/scripts/create-local-release.ps1 index f0b9c4e..d8838d4 100644 --- a/scripts/create-local-release.ps1 +++ b/scripts/create-local-release.ps1 @@ -1,15 +1,20 @@ [CmdletBinding()] param( [switch]$Launch, - [switch]$SkipNativeTests + [switch]$SkipNativeTests, + [string]$Version ) $ErrorActionPreference = 'Stop' $repositoryRoot = Split-Path -Parent $PSScriptRoot -$outputDirectory = Join-Path $repositoryRoot 'releases/win-x64' +$releaseRoot = Join-Path $repositoryRoot 'releases' +$publishDirectory = Join-Path $repositoryRoot 'artifacts/release-publish/win-x64' +$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' $privacyTests = Join-Path $repositoryRoot 'tests/ClipDiff.Windows.Tests/ClipDiff.Windows.Tests.csproj' +$nativeDll = Join-Path $repositoryRoot 'artifacts/native/Release/ClipDiff.ShellExtension.dll' +$requiredPayload = @('ClipDiff.exe', 'ClipDiff.ShellExtension.dll') 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.' @@ -22,6 +27,30 @@ if ($LASTEXITCODE -ne 0 -or -not $sdkVersion.StartsWith('10.')) { Push-Location $repositoryRoot try { + if ([string]::IsNullOrWhiteSpace($Version)) { + $versionOutput = & dotnet msbuild $applicationProject -nologo -getProperty:Version + if ($LASTEXITCODE -ne 0) { throw 'Could not read the ClipDiff version from MSBuild.' } + $Version = ($versionOutput | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 1).Trim() + } + + if ($Version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$') { + 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 + if ($SkipNativeTests) { Write-Host 'Skipping native Explorer integration tests; the extension DLL will still be built.' } @@ -37,16 +66,55 @@ try { --configuration Release ` --runtime win-x64 ` --self-contained true ` - --output $outputDirectory ` + --output $publishDirectory ` + -p:Version=$Version ` -p:PublishSingleFile=true ` -p:PublishTrimmed=false ` -p:IncludeNativeLibrariesForSelfExtract=true if ($LASTEXITCODE -ne 0) { throw 'ClipDiff publish failed.' } - Copy-Item (Join-Path $repositoryRoot 'artifacts/native/Release/ClipDiff.ShellExtension.dll') $outputDirectory -Force + 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 + + 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 ', ')." + } + } + 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. + 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 $outputDirectory 'ClipDiff.exe' - Write-Host "Published ClipDiff to $executable" + $executable = Join-Path $packageDirectory 'ClipDiff.exe' + Write-Host '' + Write-Host "Release directory: $packageDirectory" + Write-Host "Release archive: $archivePath" + Write-Host "Payload: $($requiredPayload -join ', ')" + Write-Host 'Install: Extract both files into the same directory, then run ClipDiff.exe.' if ($Launch) { Start-Process $executable