diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2ade203..e79f81a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -40,7 +40,7 @@ body: attributes: label: WinMedic version description: Output of `winmedic.exe --version` - placeholder: "WinMedic 0.3.4" + placeholder: "WinMedic 0.4.1" validations: required: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18231b3..2731784 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -253,20 +253,27 @@ jobs: # inferred from the commands that were supposed to make it land. # # `main` is protected (one approving review, four strict status checks), - # and GITHUB_TOKEN is not allowed through branch protection. There are two - # ways to give this step a path that works, and it fails until one of them - # exists: + # and GITHUB_TOKEN is not allowed through branch protection. Three things + # make this step pass, and it fails until one of them is true: # + # * run `./scripts/prepare-release.ps1 ` and merge what it + # opens *before* releasing. The bump is then already on the branch, + # "Write the version into the tree" finds nothing to write, this step + # confirms what is already true, and no credential anywhere is allowed + # past branch protection. This is the supported path — see + # CONTRIBUTING.md, "Cutting a release"; or # * set a RELEASE_TOKEN secret — a PAT with `contents: write` owned by # an account with admin rights, which "Do not allow bypassing" leaves - # unblocked — and the bump lands on the branch directly; or + # unblocked — and the bump lands on the branch directly. Convenient, + # and a long-lived token that can write to a protected branch is + # exactly the credential worth not having; or # * turn on Settings → Actions → General → Workflow permissions → # "Allow GitHub Actions to create and approve pull requests", and it # arrives as a pull request for review instead. # - # Only the first of those makes this step green. The branch is the sole - # measure: an open pull request is a path to the branch being correct, not - # a substitute for it, and a run that has not updated the branch is red + # Only the first two make this step green. The branch is the sole measure: + # an open pull request is a path to the branch being correct, not a + # substitute for it, and a run that has not updated the branch is red # whatever the reason. That is deliberate — v0.3.4 went unnoticed for two # releases precisely because a run that left the branch stale was allowed # to pass. @@ -370,7 +377,7 @@ jobs: $summary += "" $summary += "Until that is merged, a build from ``$env:BRANCH`` reports the previous version through CARGO_PKG_VERSION — the header, ``--version``, the HTML report and the update check all read it, so the tool will offer its own release as an available update." $summary += "" - $summary += "This run stays red once it is merged, as the record that the branch was not updated automatically; re-running it will not help, because ``Resolve version`` refuses a tag that already exists. **To make this step pass by itself in future,** set a ``RELEASE_TOKEN`` secret so the bump lands on the branch directly." + $summary += "This run stays red once it is merged, as the record that the branch was not updated automatically; re-running it will not help, because ``Resolve version`` refuses a tag that already exists. **To avoid this next time,** run ``./scripts/prepare-release.ps1 `` and merge its pull request *before* starting the release, so the bump is already on the branch when this step checks." } else { Write-Host "::error::$env:BRANCH was not brought up to $env:TAG and no pull request carries the bump." $summary += "### The branch was not brought up to ``$env:TAG``" @@ -383,7 +390,7 @@ jobs: $summary += "" $summary += "**To recover this release:** open a pull request from ``$head`` (it was pushed and still holds the bump), or run ``./scripts/set-version.ps1 $($env:TAG.TrimStart('v'))`` on a branch and merge that." $summary += "" - $summary += "**To stop it happening again:** set a ``RELEASE_TOKEN`` secret, or turn on Settings -> Actions -> General -> Workflow permissions -> 'Allow GitHub Actions to create and approve pull requests'." + $summary += "**To stop it happening again:** run ``./scripts/prepare-release.ps1 `` and merge its pull request before starting the release, so the bump reaches the branch the same reviewed way every other change does. The alternatives are a ``RELEASE_TOKEN`` secret, or Settings -> Actions -> General -> Workflow permissions -> 'Allow GitHub Actions to create and approve pull requests'." } if ($env:GITHUB_STEP_SUMMARY) { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2588486..766299c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,24 +90,46 @@ string. Use `utils::cmd::ps_single_quoted` — see the module documentation ther ## Cutting a release -Releases are cut from the **Run workflow** button on the -[Release workflow](../../actions/workflows/release.yml) — type the version -(`0.3.3`) and nothing else is needed. The workflow writes that version into -every file that states one, commits it, tags the commit, builds from the tag, -refuses to publish if the binary does not introduce itself as that version, and -then brings `main` up to date. - -Do not edit `Cargo.toml` by hand to bump the version. `Cargo.lock`, the README -checksum example and the issue-template placeholder all repeat it, and the one -place the number actually matters — `env!("CARGO_PKG_VERSION")`, which feeds the -header, the help popup, `--version`, the HTML report and the update check — is -the one nobody remembers to check. Use the script the workflow uses: +A release is two steps: land the version bump on `main`, then run the workflow. + +```powershell +./scripts/prepare-release.ps1 0.3.3 # opens the "chore(release): v0.3.3" pull request +# merge it, wait for CI on main, then: +gh workflow run release.yml --ref main -f version=0.3.3 +``` + +The bump goes through a pull request rather than being pushed to `main` by the +workflow because `main` is protected and `GITHUB_TOKEN` is not allowed through +its four required checks. The workflow does try — a direct push, then a pull +request as a fallback — and the fallback needs a repository setting that is +deliberately off, so the attempt fails and the run's last step goes red. Giving +CI a token that bypasses branch protection would fix the symptom by removing the +protection; sending the bump down the same reviewed, CI-gated road as every +other change costs one merge and removes nothing. v0.4.0 is the release that +shipped correctly and still went red this way. + +Preparing first also makes the workflow's own bump step a no-op: it finds every +version site already correct, tags `HEAD` unchanged, and its "did the bump reach +the branch" check passes. Everything else it does is unchanged — it builds from +the tag it just made and refuses to publish a binary that does not introduce +itself as that version. + +Do not edit `Cargo.toml` by hand to bump the version. `Cargo.lock` and the +issue-template placeholder repeat it, and the one place the number actually +matters — `env!("CARGO_PKG_VERSION")`, which feeds the header, the help popup, +`--version`, the HTML report and the update check — is the one nobody remembers +to check. `prepare-release.ps1` calls the script that owns all of them, and it +can be run on its own: ```powershell ./scripts/set-version.ps1 0.3.3 # rewrite every version site ./scripts/set-version.ps1 0.3.3 -Check # report what disagrees, change nothing ``` +The README's checksum example is deliberately not on that list: it globs +`winmedic-v*.exe` out of the download directory instead of naming a version, so +it never goes stale. + Pushing a `v*` tag by hand still builds and publishes, but a tag is immutable, so that path can only run the `-Check` pass: if the tagged tree states a different version than the tag, the release fails rather than shipping a diff --git a/Cargo.lock b/Cargo.lock index 3d2ba0f..82078c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3927,7 +3927,7 @@ dependencies = [ [[package]] name = "winmedic" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 5b3095b..a029131 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "winmedic" -version = "0.4.0" +version = "0.4.1" edition = "2024" # Verified against the locked dependency tree: edition 2024 itself needs 1.85, # but egui/eframe raises the floor to 1.95. CI enforces this exact version in diff --git a/README.md b/README.md index 31f9a59..ab583e0 100644 --- a/README.md +++ b/README.md @@ -228,8 +228,9 @@ WinMedic is **not code-signed**, so Windows SmartScreen will warn you on first l ```powershell # Compare the published checksum against the file you downloaded -$expected = (Get-Content .\winmedic-v0.4.0.exe.sha256).Split(' ')[0] -$actual = (Get-FileHash .\winmedic-v0.4.0.exe -Algorithm SHA256).Hash.ToLower() +$exe = Get-Item .\winmedic-v*.exe | Select-Object -First 1 +$expected = (Get-Content "$($exe.FullName).sha256").Split(' ')[0] +$actual = (Get-FileHash $exe.FullName -Algorithm SHA256).Hash.ToLower() if ($expected -eq $actual) { "OK - checksum matches" } else { "MISMATCH - do not run this file" } ``` diff --git a/docs/release-notes/v0.4.1.md b/docs/release-notes/v0.4.1.md new file mode 100644 index 0000000..2410a12 --- /dev/null +++ b/docs/release-notes/v0.4.1.md @@ -0,0 +1,53 @@ +# WinMedic v0.4.1 + +The desktop window that arrived in v0.4.0 gets the interface it was missing, and three findings that survived their own repair stop being raised. + +Nothing here changes what WinMedic does to a system beyond the three checks below, which now measure what their repairs can actually reach. + +--- + +## Fixed + +### Three findings that no repair could clear + +Scan, repair, reboot, scan again — and there they were, unchanged. Each of the three measured something the repair it offered could not touch. Diagnosed against a real machine's `last_scan.json`, audit log and registry rather than from first principles. + +**`wu_reboot_pending` — a key the restart leaves behind.** The check tested whether `Component Based Servicing\RebootPending` exists. Windows creates it while servicing and files the outstanding work underneath it; the restart consumes that work and empties the key, but leaves the key itself in place, permanently. On the machine this was found on it had been sitting there empty — 0 subkeys, 0 values — while `Auto Update\RebootRequired` was absent: a restart that no restart could clear. The CBS key now counts only while it still holds entries, and the two signals a boot really does clear are read alongside it, `Auto Update\RebootRequired` and `PendingFileRenameOperations`. + +**`sys_clean_winsxs` — a repair that could not move the number.** The finding was raised on DISM's own "cleanup recommended" verdict, which also weighs backups and disabled features — reclaimable only by `/ResetBase`, which WinMedic deliberately never runs. `StartComponentCleanup`, the one repair offered here, removes superseded packages and nothing else. With 4.43 GB of backups and zero reclaimable packages the flag stayed `Yes` however often the repair ran, DISM answered "completed successfully" every time, and the audit log recorded a cleaned store. The finding now needs a reclaimable package to exist, the repair reads the store back afterwards and reports what is left, and both DISM calls pass `/English` — the output arrives in the console code page, so `from_utf8_lossy` had already cost the parser the store-size and cache lines on a German machine. + +**`sys_clean_setup_logs` — the scan finding its own log.** "13.2 MB, 2 files" were `CBS.log`, held open by TrustedInstaller since it was created a month earlier, and `dism.log`, written by this module's own `AnalyzeComponentStore` call 34 seconds before the same scan measured it. Both the measurement and the sweep now ask Windows for `DELETE` access first — the same question `remove_file` asks later, and with a fully permissive share mode the probe blocks nobody. What is counted is what can be removed; a rotated `CbsPersist_*.log` still is. + +### The issue list could not be ticked + +Nothing in the findings list responded to a click. The row was an `egui::Frame` whose response was given a click sense afterwards, and a `Frame` registers its response *after* everything drawn inside it — so the row's click target sat on top of its own checkbox and swallowed every click meant for it. The boxes were drawn, they just could not be reached. + +The row is now a `Ui` built with `UiBuilder::sense`, which registers itself before its contents and leaves the checkbox on top. Ticking a box also moves the detail pane to that finding, the way `space` already did. + +### The test suite no longer overwrites the last scan + +`save_scan_state` wrote to `%APPDATA%\WinMedic\last_scan.json` from any `App`, including the dozens the suite builds — so ticking a checkbox in a test overwrote the scan the developer's own WinMedic had left behind. Persistence now sits behind the same `SystemActions` seam that already guards the browser, the UAC prompt and restore points: off by default, switched on by the desktop front end through `enable_real_system_actions`. + +--- + +## Changed + +### A desktop interface instead of a terminal layout in a window + +v0.4.0 replaced the TUI with a native window but kept the dense terminal layout inside it. That layout is gone: a fixed sidebar, clearer page headings, larger spacing, muted dark surfaces and teal accents, Segoe UI where it is available with bundled fonts as a fallback, and navigation icons drawn as scalable geometry. + +The dashboard groups a circular health indicator, live resources and diagnostic module cards, and before the first scan it shows an explicit unscanned state rather than a health score of 100. Severity links open the corresponding findings and clear stale search and module filters. The same cards, buttons and typography carry across scanning, triage, repairs and settings; confirmation dialogs use a dimmed modal backdrop that blocks clicks on the navigation behind it; scan and repair buttons respect the busy state, and long status messages truncate with the full text on hover. + +### Severity marks are drawn, not written + +An octagon for critical, a rounded triangle for warning, a disc for info. egui's bundled fonts have no warning sign in them, so a written mark would have reached some machines as an empty box — the same reason the navigation icons are painted. They replace the `[!] CRITICAL` text badges in the list, the detail pane, the filter chips and the dashboard cards, and each carries its name for a screen reader. `Severity::badge` keeps the written form for the report file, which is now its only caller. + +--- + +## Verification + +`cargo fmt`, `cargo clippy --all-targets -- -D warnings` and the full suite pass: 361 unit and 266 integration tests, 8 of them new for the three findings above — an empty CBS key, a CBS key still holding work, a recommendation with nothing reclaimable, a cleanup that reclaimed nothing, and a log held open with `FILE_SHARE_NONE` that is neither counted nor reported as a failed deletion. The GUI tests apply the actual theme and cover all five destinations at 960×640, 1400×900 and 1920×1200. + +--- + +**Full changelog**: https://github.com/SecretLUL/WinMedic/compare/v0.4.0...v0.4.1 diff --git a/scripts/prepare-release.ps1 b/scripts/prepare-release.ps1 new file mode 100644 index 0000000..579954c --- /dev/null +++ b/scripts/prepare-release.ps1 @@ -0,0 +1,164 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Open the pull request that raises `main` to a new version, so the release + workflow finds the bump already in place. + +.DESCRIPTION + `main` is protected: four strict status checks, and `GITHUB_TOKEN` is not + allowed through them. So the release workflow's last step — the one that + brings the branch up to the version it just released — cannot land its own + commit. It tries a direct push, falls back to opening a pull request, and + that fallback needs "Allow GitHub Actions to create and approve pull + requests", which is off. v0.4.0 shipped correctly and its run still went + red for exactly this reason. + + Rather than hand CI a credential that bypasses branch protection, the bump + takes the same road as every other change: a pull request, reviewed and + CI-gated, merged by a human. The release is then cut from a `main` that + already states the version — at which point the workflow's own bump step + finds nothing to write, tags `HEAD` unchanged, and its "did the bump land + on the branch" check passes on the first try. + + This script is the first half of that. It branches off `origin/main`, runs + set-version.ps1, commits and opens the pull request: + + ./scripts/prepare-release.ps1 0.4.1 + + Merge what it opens, then release: + + gh workflow run release.yml --ref main -f version=0.4.1 + +.PARAMETER Version + The SemVer version to release. A leading "v" is accepted and stripped. + +.PARAMETER NoPush + Prepare the branch and the commit locally, but push nothing and open no + pull request. For checking what the bump would contain. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory, Position = 0)] + [string] $Version, + + [switch] $NoPush +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# git and gh report failure through exit codes, and several of the calls below +# are questions whose "no" is not an error. Every one of them is read by hand. +$PSNativeCommandUseErrorActionPreference = $false + +function Invoke-Checked { + param([string] $What, [scriptblock] $Command) + + $output = & $Command 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + throw "$What failed:`n$($output.Trim())" + } + return $output.Trim() +} + +$Version = $Version.Trim().TrimStart('v', 'V') +if ($Version -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$') { + throw "Not a SemVer version: '$Version' (expected something like 0.4.1 or 1.0.0-rc1)" +} +$tag = "v$Version" +$branch = "chore/release-$tag" +$repoRoot = Split-Path -Parent $PSScriptRoot + +Push-Location $repoRoot +try { + if (-not $NoPush -and -not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw 'The GitHub CLI (gh) is not on PATH, so the pull request cannot be opened. Install it, or re-run with -NoPush and open the pull request by hand.' + } + + # Cheapest failure available, and the same one the workflow leads with: a + # tag that already exists means this version has shipped. + Invoke-Checked 'git fetch' { git fetch origin --prune --tags --quiet } + if (Invoke-Checked 'git ls-remote' { git ls-remote --tags origin "refs/tags/$tag" }) { + throw "$tag already exists. Pick a version that has not shipped yet." + } + + # An unrelated edit sitting in the tree would be swept into the release + # commit by the `git add` below, which stages by path rather than by patch. + if (git status --porcelain --untracked-files=no) { + throw 'The working tree has uncommitted changes. Commit or stash them first — the release commit must contain the bump and nothing else.' + } + + # -B would silently reset a branch that already holds work. Refusing and + # naming the command to delete it leaves that decision where it belongs. + if (Invoke-Checked 'git branch --list' { git branch --list $branch }) { + throw "The branch $branch already exists locally. Delete it first (git branch -D $branch), or finish what is on it." + } + + Write-Host "Branching $branch off origin/main" + Invoke-Checked 'git checkout' { git checkout -q -b $branch origin/main } + + & (Join-Path $PSScriptRoot 'set-version.ps1') $Version + if ($LASTEXITCODE -ne 0) { + throw "set-version.ps1 failed, so nothing was committed. The branch $branch is still checked out." + } + + # The workflow prefers docs/release-notes/.md over GitHub's generated + # commit list, and this is the last moment where writing one is convenient: + # after the merge it would take a second pull request. + $notes = Join-Path $repoRoot "docs/release-notes/$tag.md" + if (-not (Test-Path -LiteralPath $notes)) { + Write-Host '' + Write-Host " note docs/release-notes/$tag.md does not exist." -ForegroundColor Yellow + Write-Host ' The release will fall back to GitHub-generated notes. Writing it now,' -ForegroundColor Yellow + Write-Host ' before this pull request is merged, is cheaper than a second one after.' -ForegroundColor Yellow + } + + Write-Host '' + if (-not (git status --porcelain --untracked-files=no)) { + throw "Every version site already states $Version and there is nothing to commit. If $tag is genuinely unreleased, main is already prepared for it — run: gh workflow run release.yml --ref main -f version=$Version" + } + + # --update stages tracked files only, so an untracked release-notes file + # written into the tree by hand has to be added deliberately. + Invoke-Checked 'git add' { git add --update } + Invoke-Checked 'git commit' { git commit -q -m "chore(release): $tag" } + Write-Host "Committed chore(release): $tag" + + if ($NoPush) { + Write-Host '' + Write-Host "Nothing was pushed. The commit is on $branch; review it with: git show" + return + } + + Invoke-Checked 'git push' { git push -q -u origin $branch } + Write-Host "Pushed $branch" + + $body = @" +Raises the version to ``$tag`` across every file that states one. + +Merging this before the release runs is what lets the release finish green: the +workflow's own bump step cannot push to a protected ``main``, so it finds the +version already in place instead, tags ``HEAD`` unchanged, and its branch check +passes. See ``scripts/prepare-release.ps1`` for the reasoning. + +After this is merged: + +`````` +gh workflow run release.yml --ref main -f version=$Version +`````` +"@ + + $created = Invoke-Checked 'gh pr create' { + gh pr create --base main --head $branch --title "chore(release): $tag" --body $body + } + $url = ($created -split "`n" | Where-Object { $_ -match '^\s*https://' } | Select-Object -First 1) + + Write-Host '' + Write-Host "Pull request: $($url ?? $created)" + Write-Host '' + Write-Host 'Next, once it is merged and CI on main is green:' + Write-Host " gh workflow run release.yml --ref main -f version=$Version" +} +finally { + Pop-Location +} diff --git a/scripts/set-version.ps1 b/scripts/set-version.ps1 index 7ca44f5..2a9db41 100644 --- a/scripts/set-version.ps1 +++ b/scripts/set-version.ps1 @@ -67,12 +67,12 @@ $targets = @( Pattern = '(?m)^(name = "winmedic"\r?\nversion = )"[^"]*"' Replacement = "`${1}`"$Version`"" } - @{ - Path = 'README.md' - Purpose = 'the checksum-verification example' - Pattern = 'winmedic-v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\.exe' - Replacement = "winmedic-v$Version.exe" - } + # README.md is deliberately absent. Its checksum example used to name + # `winmedic-v.exe` twice and was rewritten here every release; it + # now globs `winmedic-v*.exe` out of the download directory instead, so it + # states no version and there is nothing to keep in step. Do not add it back + # without first re-introducing a literal version into the file — a target + # whose pattern never matches makes this script throw. @{ Path = '.github/ISSUE_TEMPLATE/bug_report.yml' Purpose = 'the "WinMedic version" placeholder'