diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c20edd..c788a48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,15 +26,16 @@ jobs: run: '"version=$($env:GITHUB_REF_NAME -replace ''^v'','''')" | Out-File -FilePath $env:GITHUB_OUTPUT -Append' # UpdateService compares the running assembly version against the newest release tag, so a - # csproj that lags the tag ships a binary that reports an update to itself (issue #33). - - name: Check csproj version matches the tag + # declared version that lags the tag ships a binary that reports an update to itself + # (issue #33). ./release.ps1 bumps and tags together so this should never fire (issue #46). + - name: Check declared version matches the tag shell: pwsh run: | - $declared = ([xml](Get-Content src/ExplorerHelper/ExplorerHelper.csproj)).Project.PropertyGroup.Version | + $declared = ([xml](Get-Content Directory.Build.props)).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 $tag = '${{ steps.version.outputs.version }}' if ($declared -ne $tag) { - throw "ExplorerHelper.csproj is '$declared' but the tag says '$tag'. Bump the csproj and build.ps1's -Version default, then re-tag." + throw "Directory.Build.props is '$declared' but the tag says '$tag'. Cut releases with ./release.ps1 -Version $tag, which bumps and tags together." } - name: Build zip + installer diff --git a/CLAUDE.md b/CLAUDE.md index 5ad1e2c..93c046e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,12 +27,13 @@ dotnet run --project src/ExplorerHelper -- "C:\some\folder" # or run the built exe directly: src/ExplorerHelper/bin/Debug/net8.0-windows/ExplorerHelper.exe "C:\some\folder" -# Publish self-contained exe + portable zip into artifacts/ (also the CI smoke test) -# build.ps1's -Version default and the csproj both track the newest tag (0.6.0). -# Bump both when cutting a release — release.yml fails the build if they disagree with the tag, -# because a lagging version makes every local build think an update is waiting (issue #33). -./build.ps1 -Version 0.6.0 -./build.ps1 -Version 0.6.0 -Installer # also builds Inno Setup installer +# Publish self-contained exe + portable zip into artifacts/ (also the CI smoke test). +# Version comes from Directory.Build.props; -Version only overrides it. +./build.ps1 +./build.ps1 -Installer # also builds Inno Setup installer + +# Cut a release: bumps the props file, commits, tags, and pushes both (issue #46). +./release.ps1 -Version 0.7.0 ``` There is **no test project**. Verify changes by driving the running app (see below). @@ -89,6 +90,14 @@ Inside `src/ExplorerHelper`: see in a stack trace. Navigation is covered centrally: `MainViewModel.FolderChanging` fires before every folder switch and `MainWindow` clears the preview on it, so command-bound toolbar buttons and code-behind paths both release handles without each remembering to. +- **The version lives in `Directory.Build.props` only** (issue #46). Both projects inherit it and + `build.ps1` defaults to it, so there is no second number to keep in step — the shell extension sat + at 0.1.0 for five releases because it had one. `release.yml` fails a tag that disagrees with the + props file, since a lagging version makes a build from source see the newer tag and show a + permanent "update available" pill (issue #33). Bump it with `./release.ps1 -Version x.y.z`, which + bumps, commits, tags and pushes together so the two can't drift apart. Note the declared version + never reaches a *released* binary: `build.ps1` passes `-p:Version` from the tag, which overrides + it. It only affects local builds. - **Settings persistence:** user prefs live in `AppSettings` → `%APPDATA%\ExplorerHelper\settings.json`. It's forgiving of missing/corrupt files (falls back to defaults) and `Normalized()` fills in nulls from older files. New persisted settings: add a property, default it, and normalize it. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..53cd180 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,19 @@ + + + + + 0.6.0 + + + diff --git a/README.md b/README.md index e130b0e..186fea5 100644 --- a/README.md +++ b/README.md @@ -72,22 +72,30 @@ dotnet run --project src/ExplorerHelper ## Releasing -CI builds every push and PR (`.github/workflows/ci.yml`). To cut a release, bump `` in -`src/ExplorerHelper/ExplorerHelper.csproj` and the `-Version` default in `build.ps1` to match, then -tag: +From an up-to-date `main` with a clean tree: ```powershell -git tag v0.6.0 -git push origin v0.6.0 +./release.ps1 -Version 0.7.0 ``` -The release workflow derives the version from the tag, builds the zip and installer with it, and -attaches both to a GitHub Release with generated notes. +That bumps `Directory.Build.props`, commits, tags `v0.7.0`, and pushes the commit and tag. The +release workflow then builds the zip and installer and attaches them to a GitHub Release with +generated notes. Watch it with `gh run watch`. -> The workflow stops if `` and the tag disagree. Shipped binaries carry the tag either -> way, so the mismatch is harmless for users — but a stale csproj makes every build from source -> report the old number, see the newer tag on GitHub, and show a permanent "update available" -> pill. The two move together. +Add `-WhatIf` to see the plan without touching anything, or `-Branch ` to cut from somewhere +other than `main`. The script refuses to run on the wrong branch, with uncommitted changes, out of +sync with origin, or onto a tag that already exists — all checked before it writes anything. + +**The version lives in `Directory.Build.props` and nowhere else.** Both projects inherit it and +`build.ps1` defaults to it. Don't edit it by hand: `release.yml` refuses to build a tag that +disagrees with it, and tagging a commit that predates the bump is how v0.6.0 failed (issue #46). + +If a release does fail the version check, the tag is pointing at the wrong commit. Land the bump, +then move it: + +```powershell +git checkout main; git pull; git tag -f v0.7.0; git push origin -f v0.7.0 +``` ## Tech notes diff --git a/build.ps1 b/build.ps1 index 3747c7d..6d0e8ca 100644 --- a/build.ps1 +++ b/build.ps1 @@ -2,17 +2,28 @@ .SYNOPSIS Builds Explorer Helper. Same script used locally and by GitHub Actions. .EXAMPLE - ./build.ps1 # publish self-contained exe + zip - ./build.ps1 -Version 1.2.3 -Installer # also compile the Inno Setup installer + ./build.ps1 # version from Directory.Build.props; exe + zip + ./build.ps1 -Version 1.2.3 -Installer # override, and compile the Inno Setup installer #> param( [string]$Configuration = 'Release', - [string]$Version = '0.6.0', + [string]$Version, [switch]$Installer ) $ErrorActionPreference = 'Stop' $root = $PSScriptRoot + +# Default to whatever Directory.Build.props says, so there's no second number to keep in step. +# CI passes -Version from the tag; release.yml has already checked the two agree. +if (-not $Version) { + $props = Get-Content (Join-Path $root 'Directory.Build.props') -Raw + if ($props -notmatch '(.*?)') { + Write-Error 'No in Directory.Build.props, and no -Version given.' + } + $Version = $Matches[1] + Write-Host "Version $Version (from Directory.Build.props)" +} $publishDir = Join-Path $root 'artifacts\publish' dotnet publish (Join-Path $root 'src\ExplorerHelper\ExplorerHelper.csproj') ` diff --git a/release.ps1 b/release.ps1 new file mode 100644 index 0000000..6efc768 --- /dev/null +++ b/release.ps1 @@ -0,0 +1,101 @@ +<# +.SYNOPSIS + Cuts a release: bumps the version, commits, tags, and pushes both in one step. +.DESCRIPTION + The bump and the tag have to travel together — release.yml refuses to build a tag whose + version doesn't match Directory.Build.props, and tagging a commit that predates the bump is + how v0.6.0 failed the first time (issue #46). Doing both here makes that ordering mistake + impossible. + + Everything is checked before anything is written, so a failed precondition leaves the repo + exactly as it was. +.EXAMPLE + ./release.ps1 -Version 0.7.0 +.EXAMPLE + ./release.ps1 -Version 0.7.0 -WhatIf # print the plan, touch nothing +#> +param( + [Parameter(Mandatory)] + [ValidatePattern('^\d+\.\d+\.\d+$')] + [string]$Version, + + # Cut from somewhere other than main (a hotfix branch, say). + [string]$Branch = 'main', + + [switch]$WhatIf +) + +$ErrorActionPreference = 'Stop' +$root = $PSScriptRoot +$propsPath = Join-Path $root 'Directory.Build.props' +$tag = "v$Version" + +function Fail($message) { + Write-Host "release: $message" -ForegroundColor Red + exit 1 +} + +# --- Preconditions ------------------------------------------------------------------ +# All of them run before the first write, so a rejected release leaves nothing half-done. + +$current = (git -C $root rev-parse --abbrev-ref HEAD).Trim() +if ($current -ne $Branch) { + Fail "on '$current', expected '$Branch'. Switch branches or pass -Branch $current." +} + +if ((git -C $root status --porcelain)) { + Fail 'working tree has uncommitted changes. Commit or stash them first.' +} + +git -C $root fetch origin --quiet --tags +$local = (git -C $root rev-parse HEAD).Trim() +$remote = (git -C $root rev-parse "origin/$Branch").Trim() +if ($local -ne $remote) { + Fail "$Branch is out of sync with origin/$Branch. Pull (or push) first." +} + +$existing = git -C $root tag --list $tag +if ($existing) { + Fail "$tag already exists locally. Delete it first (git tag -d $tag) if you mean to redo it." +} +if (git -C $root ls-remote --tags origin $tag) { + Fail "$tag already exists on origin. Pick the next version, or delete the tag if it never shipped." +} + +if (-not (Test-Path $propsPath)) { Fail "cannot find $propsPath." } +$props = Get-Content $propsPath -Raw +if ($props -notmatch '(.*?)') { Fail 'no element in Directory.Build.props.' } +$fromVersion = $Matches[1] +if ($fromVersion -eq $Version) { + Fail "Directory.Build.props already says $Version. Nothing to bump." +} + +Write-Host "release: $fromVersion -> $Version on $Branch, tagging $tag" -ForegroundColor Cyan +if ($WhatIf) { + Write-Host ' (WhatIf) would rewrite Directory.Build.props, commit, tag, and push.' -ForegroundColor Yellow + exit 0 +} + +# --- Bump, commit, tag, push -------------------------------------------------------- + +($props -replace '.*?', "$Version") | + Set-Content -Path $propsPath -Encoding utf8 -NoNewline + +git -C $root add Directory.Build.props +git -C $root commit -m "Release $Version" +if ($LASTEXITCODE -ne 0) { Fail 'commit failed.' } + +git -C $root tag $tag +if ($LASTEXITCODE -ne 0) { Fail "tagging failed. The bump is committed but unpushed - git reset --hard HEAD~1 to undo." } + +# Commit first: a tag arriving at origin before the commit it points at would start a release +# build against a ref the runner can't check out. +git -C $root push origin $Branch +if ($LASTEXITCODE -ne 0) { Fail "push failed. Local commit and tag stand; re-run 'git push origin $Branch' once resolved." } + +git -C $root push origin $tag +if ($LASTEXITCODE -ne 0) { Fail "tag push failed. Re-run 'git push origin $tag' to start the release." } + +Write-Host "" +Write-Host "Pushed $tag. release.yml is building the zip and installer now:" -ForegroundColor Green +Write-Host " gh run watch (or) https://github.com/JacobPoteet/ExplorerHelper/actions" diff --git a/src/ExplorerHelper.ShellExtension/ExplorerHelper.ShellExtension.csproj b/src/ExplorerHelper.ShellExtension/ExplorerHelper.ShellExtension.csproj index 4f4a82e..08e69ad 100644 --- a/src/ExplorerHelper.ShellExtension/ExplorerHelper.ShellExtension.csproj +++ b/src/ExplorerHelper.ShellExtension/ExplorerHelper.ShellExtension.csproj @@ -14,7 +14,8 @@ Explorer Helper Shell Extension Windows 11 top-level context menu handler for Explorer Helper. - 0.1.0 + diff --git a/src/ExplorerHelper/ExplorerHelper.csproj b/src/ExplorerHelper/ExplorerHelper.csproj index 3e05413..2bbf05d 100644 --- a/src/ExplorerHelper/ExplorerHelper.csproj +++ b/src/ExplorerHelper/ExplorerHelper.csproj @@ -9,7 +9,7 @@ Explorer Helper Explorer Helper Clean and organize folders straight from the Explorer context menu. - 0.6.0 + app.manifest