Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Version> is '$declared' but the tag says '$tag'. Bump the csproj and build.ps1's -Version default, then re-tag."
throw "Directory.Build.props <Version> is '$declared' but the tag says '$tag'. Cut releases with ./release.ps1 -Version $tag, which bumps and tags together."
}

- name: Build zip + installer
Expand Down
21 changes: 15 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Version> 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).
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project>

<!--
The single source of truth for the product version. MSBuild imports this into every project
under the repo root, so the app and the shell extension stay in step without either csproj
naming a number of its own.

Everything else derives from it: build.ps1 defaults -Version to this, passes it to
dotnet publish, hands it to Inno Setup as /DAppVersion, and names the zip after it.
release.ps1 rewrites the line below, commits, and tags in one step; release.yml checks the
result against the tag before it builds.

Bump it with `./release.ps1 -Version x.y.z` rather than by hand (issue #46).
-->
<PropertyGroup>
<Version>0.6.0</Version>
</PropertyGroup>

</Project>
30 changes: 19 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Version>` 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 `<Version>` 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 <name>` 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

Expand Down
17 changes: 14 additions & 3 deletions build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<Version>(.*?)</Version>') {
Write-Error 'No <Version> 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') `
Expand Down
101 changes: 101 additions & 0 deletions release.ps1
Original file line number Diff line number Diff line change
@@ -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 '<Version>(.*?)</Version>') { Fail 'no <Version> 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>.*?</Version>', "<Version>$Version</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"
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@

<AssemblyTitle>Explorer Helper Shell Extension</AssemblyTitle>
<Description>Windows 11 top-level context menu handler for Explorer Helper.</Description>
<Version>0.1.0</Version>
<!-- Version comes from Directory.Build.props. It sat at 0.1.0 while the app reached 0.6.0
precisely because it was a separate number nobody remembered to bump (issue #46). -->
</PropertyGroup>

</Project>
2 changes: 1 addition & 1 deletion src/ExplorerHelper/ExplorerHelper.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<AssemblyTitle>Explorer Helper</AssemblyTitle>
<Product>Explorer Helper</Product>
<Description>Clean and organize folders straight from the Explorer context menu.</Description>
<Version>0.6.0</Version>
<!-- Version comes from Directory.Build.props; ./release.ps1 bumps it (issue #46). -->
<ApplicationManifest>app.manifest</ApplicationManifest>
<!-- Embeds the icon in the exe's Win32 resource: the Explorer context-menu entry
(Icon = the exe path) and the taskbar/alt-tab window icon both resolve to it. -->
Expand Down