From 784ffdd89412610b22eb7f70dac4d15bba6df12d Mon Sep 17 00:00:00 2001 From: JacobPoteet Date: Sat, 22 Aug 2026 19:22:36 -0400 Subject: [PATCH] Single-source the version and add release.ps1 (#46) Tagging v0.6.0 failed the release build because the csproj still said 0.5.0. Fixing it took a bump PR, a merge, and a force-moved tag. Two things made that possible: the version lived in more than one manual place, and bumping and tagging were separate acts that could happen in the wrong order. Directory.Build.props now holds the version and both projects inherit it, so neither csproj names a number and build.ps1 defaults to it rather than carrying its own. That also unsticks the shell extension, which had been stamping 0.1.0 since it was created for exactly this reason - it was a separate number nobody remembered. release.ps1 makes the ordering mistake impossible by doing the whole thing at once. It checks the branch, a clean tree, sync with origin and that the tag is free, all before writing anything, then bumps, commits, tags, and pushes the commit before the tag so the runner never sees a ref it cannot check out. release.yml reads the props file and points its error at release.ps1. It stays as a backstop rather than the thing expected to catch this. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 9 +- CLAUDE.md | 21 ++-- Directory.Build.props | 19 ++++ README.md | 30 ++++-- build.ps1 | 17 ++- release.ps1 | 101 ++++++++++++++++++ .../ExplorerHelper.ShellExtension.csproj | 3 +- src/ExplorerHelper/ExplorerHelper.csproj | 2 +- 8 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 Directory.Build.props create mode 100644 release.ps1 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