From bd76b5fae3590bf7ba62ca8e580cc20af3439673 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Wed, 2 Sep 2026 10:22:37 +0200 Subject: [PATCH 1/3] chore: store every text file as LF .gitattributes loses its per-type pins, so a new file type needs no decision, and the explicit eol= overrides whatever core.autocrlf a contributor has. The batch and shell scripts are replaced by three PowerShell scripts. tidy-code.ps1 also rewrites the line endings XamlStyler cannot: the tool always writes the host newline, which on Windows left every XAML file listed as modified with an empty diff. Its -Check mode formats the tree and asks whether that changed anything, so the lint job now gives the same answer on every OS. Co-Authored-By: Claude Opus 5 --- .editorconfig | 12 +- .gitattributes | 45 +++---- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/ci.yml | 103 +++++++++++---- AGENTS.md | 6 +- CONTRIBUTING.md | 32 ++++- scripts/clean-build-artifacts.ps1 | 99 ++++++++++++++ scripts/clean.cmd | 4 - scripts/clean.sh | 5 - scripts/extract-release-notes.ps1 | 106 +++++++++++++++ scripts/extract-release-notes.sh | 27 ---- scripts/formatCode.cmd | 10 -- scripts/formatCode.sh | 11 -- scripts/tidy-code.ps1 | 208 ++++++++++++++++++++++++++++++ 14 files changed, 547 insertions(+), 123 deletions(-) create mode 100644 scripts/clean-build-artifacts.ps1 delete mode 100644 scripts/clean.cmd delete mode 100755 scripts/clean.sh create mode 100644 scripts/extract-release-notes.ps1 delete mode 100755 scripts/extract-release-notes.sh delete mode 100644 scripts/formatCode.cmd delete mode 100755 scripts/formatCode.sh create mode 100644 scripts/tidy-code.ps1 diff --git a/.editorconfig b/.editorconfig index 5992afe..a89d434 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,5 +1,13 @@ root = true +# ====================================================================================================== +# Every file +# +# Line endings are LF everywhere, on every OS. .gitattributes stores LF and checks out LF - that is the +# guarantee, and it holds whatever core.autocrlf a contributor has. end_of_line below is what makes the +# editors and formatters write LF too, so the working tree never drifts. +# ====================================================================================================== + [*] charset = utf-8 trim_trailing_whitespace = true @@ -7,10 +15,6 @@ insert_final_newline = true indent_style = space tab_width = 4 indent_size = 4 -end_of_line = crlf - -# Shell scripts require LF (see .gitattributes). -[*.sh] end_of_line = lf # YAML conventionally uses 2-space indentation. diff --git a/.gitattributes b/.gitattributes index 541efcb..b3d3a21 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,31 +1,16 @@ -# Auto-detect text files, normalize to LF in the repo -* text=auto +# Every text file is LF, in the repository and in the working tree, on every OS. +# Git auto-detects text vs. binary; the rules below are the exceptions. +# +# The explicit eol= overrides core.autocrlf and core.eol, so a contributor needs no git configuration +# and a wrong one cannot change what lands in the repository. CI fails if a file is ever stored +# otherwise. +* text=auto eol=lf -# Force specific line endings where it matters -*.cs text eol=crlf -*.csproj text eol=crlf -*.slnf text eol=crlf -*.slnx text eol=crlf -*.props text eol=crlf -*.targets text eol=crlf -*.xaml text eol=crlf -*.axaml text eol=crlf -*.resx text eol=crlf -*.manifest text eol=crlf -*.json text eol=lf -*.yml text eol=lf -*.yaml text eol=lf -*.md text eol=lf - -# Windows batch files require CRLF; shell scripts require LF. -*.cmd text eol=crlf -*.bat text eol=crlf -*.sh text eol=lf - -# Binary files - never touch -*.png binary -*.jpg binary -*.ico binary -*.dll binary -*.exe binary -*.snk binary +# Binary files - never convert. text=auto already detects most of these; the list makes it certain. +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.snk binary +*.dll binary +*.exe binary diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ae08953..605d3cd 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,4 +13,4 @@ - [ ] Public API changes are declared in the affected `PublicAPI.Unshipped.txt`. - [ ] XML docs and `README.md` updated for public API changes. - [ ] `CHANGELOG.md` updated under *Unreleased*. -- [ ] Code formatted (`scripts/formatCode.cmd` / `scripts/formatCode.sh`). +- [ ] Code formatted (`pwsh -File scripts/tidy-code.ps1`). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6126c0..8b05476 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,24 +34,27 @@ env: NUGET_CONFIG: NuGet.config PACKAGE_OUTPUT: artifacts/packages RELEASE_NOTES_FILE: release-notes.md - RELEASE_NOTES_SCRIPT: scripts/extract-release-notes.sh + RELEASE_NOTES_SCRIPT: scripts/extract-release-notes.ps1 SOLUTION: ResXLocalization.slnx SOLUTION_FILTER_NON_WINDOWS: ResXLocalization.NonWindows.slnf - SAMPLES_DIRECTORY: samples - SOURCE_DIRECTORY: src TEST_RESULTS: artifacts/test-results WPF_CONSUMER_PROJECT: tests/package-consumption/WpfConsumer/WpfConsumer.csproj WPF_TEST_PROJECT: tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj jobs: - # Enforce the formatting the repo ships tooling for (dotnet format + XamlStyler): a style rule that - # is not enforced in CI drifts with the first external pull request. - # Windows runner: XamlStyler always writes the host OS's newline and has no line-ending setting, - # and .gitattributes checks the XAML files out as CRLF everywhere — so the passive check can only - # pass on Windows. + # Enforce the formatting the repo ships tooling for: a rule that is not enforced in CI drifts with + # the first external pull request. + # + # This runs scripts/tidy-code.ps1, the same entry point a developer uses. -Check formats the checkout + # for real and then asks whether anything changed: XamlStyler has no usable check mode here, because + # it compares against its own CRLF output and so fails on LF files on Windows. Writing to the + # checkout is harmless in CI; it is thrown away at the end of the job. + # + # Ubuntu is a cost and speed choice, not a correctness one: -Check gives the same answer on every OS, + # and on Linux the non-Windows solution filter keeps the WPF projects out of the restore. lint: name: Lint - runs-on: windows-latest + runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -59,6 +62,60 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false + # Full history, for the .git-blame-ignore-revs check below; at depth 1 no revision it names + # would resolve. + fetch-depth: 0 + + # .gitattributes only applies when a file is ADDED. A file committed past it, or a rule added + # after the file, stays wrong forever and nothing complains - and commits created server-side, + # such as a bot's, bypass the filter entirely. Renormalizing and asking whether anything changed + # is the exact question, and it needs no toolchain. + - name: Verify line endings are normalized + run: | + git add --renormalize . + if ! git diff --cached --quiet; then + git --no-pager diff --cached --name-only + echo "::error title=Line endings::The files above are not stored as .gitattributes requires. Run 'git add --renormalize .' and commit the result." + exit 1 + fi + + # .git-blame-ignore-revs names commits by raw SHA, and git does NOT complain about one it cannot + # resolve - it silently skips the entry. A rebase-merge or squash-merge rewrites every SHA on a + # branch, which is exactly how that happens. This step is what makes it visible. + - name: Verify .git-blame-ignore-revs + run: | + if [ ! -f .git-blame-ignore-revs ]; then + echo "No .git-blame-ignore-revs - nothing to check." + exit 0 + fi + + status=0 + count=0 + while read -r revision _ || [ -n "${revision}" ]; do + revision="${revision%$'\r'}" + case "${revision}" in ''|\#*) continue ;; esac + count=$((count + 1)) + + if ! git rev-parse --verify --quiet "${revision}^{commit}" > /dev/null; then + echo "::error file=.git-blame-ignore-revs::${revision} is not a commit in this repository. If the branch that introduced it was rebased or squash-merged, replace it with the SHA it became." + status=1 + elif ! git merge-base --is-ancestor "${revision}" HEAD; then + echo "::error file=.git-blame-ignore-revs::${revision} is a commit, but not an ancestor of HEAD, so blame can never reach it." + status=1 + fi + done < .git-blame-ignore-revs + + if [ "${count}" -eq 0 ]; then + echo "::error file=.git-blame-ignore-revs::The file lists no revisions. Delete it if it is no longer wanted." + exit 1 + fi + + if [ "${status}" -ne 0 ]; then + echo "Checked ${count} revision(s); see the errors above." + exit 1 + fi + + echo "All ${count} revision(s) resolve and are reachable." - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -82,23 +139,13 @@ jobs: - name: Restore run: dotnet restore ${{ env.SOLUTION_FILTER_NON_WINDOWS }} --configfile ${{ env.NUGET_CONFIG }} - # Whitespace only: the semantic style and analyzer rules are already enforced by the build - # itself (EnforceCodeStyleInBuild + TreatWarningsAsErrors) in the verify jobs. - - name: Verify C# formatting (dotnet format) - run: dotnet format whitespace ${{ env.SOLUTION_FILTER_NON_WINDOWS }} --no-restore --verify-no-changes + - name: Verify formatting + run: pwsh -NoProfile -NonInteractive -File scripts/tidy-code.ps1 -Check - - name: Verify XAML formatting (XamlStyler) - shell: bash - # xstyler's passive mode exits non-zero when files fail the check; "|| true" keeps the - # step alive under "bash -e" so the output and the actionable error below still print. - # The grep on the output is what decides pass/fail. - run: | - output="$(dotnet xstyler --passive --recursive --directory "${SOURCE_DIRECTORY}" || true; dotnet xstyler --passive --recursive --directory "${SAMPLES_DIRECTORY}" || true)" - echo "${output}" - if grep -q "FAIL" <<< "${output}"; then - echo "::error title=XAML formatting::Run scripts/formatCode (dotnet xstyler --recursive --directory src && dotnet xstyler --recursive --directory samples) and commit the result." - exit 1 - fi + # The check above only says that something is unformatted. This says what. + - name: Show the changes that would fix it + if: failure() + run: git --no-pager diff --stat && git --no-pager diff # Cross-platform: build and test the shared Core, the source generator, and the Avalonia stack. These # projects target net8.0/net10.0 and build/test identically on Linux and Windows. The WPF projects are @@ -465,7 +512,8 @@ jobs: path: ${{ env.PACKAGE_OUTPUT }} - name: Validate release changelog before publication - run: ${{ env.RELEASE_NOTES_SCRIPT }} "${GITHUB_REF_NAME#v}" + shell: pwsh + run: ./${{ env.RELEASE_NOTES_SCRIPT }} -Version "$($env:GITHUB_REF_NAME -replace '^v', '')" - name: Verify the tag matches the packed version shell: bash @@ -536,7 +584,8 @@ jobs: path: ${{ env.PACKAGE_OUTPUT }} - name: Extract the changelog section for this version - run: ${{ env.RELEASE_NOTES_SCRIPT }} "${GITHUB_REF_NAME#v}" + shell: pwsh + run: ./${{ env.RELEASE_NOTES_SCRIPT }} -Version "$($env:GITHUB_REF_NAME -replace '^v', '')" - name: Create the release env: diff --git a/AGENTS.md b/AGENTS.md index 1259655..d8c997b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,7 @@ dotnet test tests/ResXLocalization.Avalonia.Sample.Tests/ResXLocalization.Avalon dotnet test tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample.Tests.csproj -c Release # Windows only # Format before committing (CI fails otherwise). -scripts\formatCode.cmd # Windows -scripts/formatCode.sh # Linux/macOS +pwsh -File scripts/tidy-code.ps1 ``` ## Conventions the build enforces (get these right or CI fails) @@ -30,6 +29,9 @@ scripts/formatCode.sh # Linux/macOS to allow it. - **Zero warnings.** `TreatWarningsAsErrors` + `AnalysisMode=All` + StyleCop + Roslynator + ErrorProne.NET. A warning is a build failure. Match the surrounding style; `.editorconfig` decides. +- **Line endings are LF everywhere.** `.gitattributes` and `.editorconfig` enforce this and CI verifies + it. Never hand-convert line endings, and never compare a multi-line source literal against + `Environment.NewLine` - the literal carries the file's bytes, `Environment.NewLine` carries the host's. - **Avalonia/WPF symmetry.** `src/ResXLocalization.Avalonia` and `src/ResXLocalization.WPF` mirror each other (markup extensions, converters, behaviors). A change to one almost always needs the mirrored change in the other, plus mirrored tests. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfaad07..eddcaa2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,8 +36,36 @@ dotnet test tests/ResXLocalization.WPF.Sample.Tests/ResXLocalization.WPF.Sample. Format the code before committing - CI enforces it: ```shell -scripts\formatCode.cmd # Windows -scripts/formatCode.sh # Linux/macOS +pwsh -File scripts/tidy-code.ps1 +``` + +## Line endings + +Every text file is LF, in the repository and in the working tree, on every OS. `.gitattributes` +enforces this whatever your `core.autocrlf` is set to, so there is nothing to configure, and CI fails +if a wrongly stored file lands anyway. + +`.editorconfig` also asks editors and formatters to write LF. XamlStyler cannot: it always writes the +host OS newline, so `scripts/tidy-code.ps1` rewrites the XAML back to LF after running it. If some +other tool writes CRLF, git still stores LF, but `git status` lists the file as modified while +`git diff` shows nothing. Run `pwsh -File scripts/tidy-code.ps1` to fix it, or `git checkout -- `. + +If you have set `git config core.safecrlf true`, git refuses to add such a file with "CRLF would be +replaced by LF". Run the tidy script first, or use `core.safecrlf warn`. + +One more one-time step after cloning, so `git blame` skips whole-repository mechanical commits (GitHub +does this automatically): + +```shell +git config blame.ignoreRevsFile .git-blame-ignore-revs +``` + +To refresh a clone made before this policy (commit or stash your changes first - the second command +discards uncommitted work): + +```shell +git rm -r --cached . -q +git reset --hard ``` ## Pull request checklist diff --git a/scripts/clean-build-artifacts.ps1 b/scripts/clean-build-artifacts.ps1 new file mode 100644 index 0000000..d67bfa7 --- /dev/null +++ b/scripts/clean-build-artifacts.ps1 @@ -0,0 +1,99 @@ +<# +.SYNOPSIS + Deletes generated build and documentation artifacts in the repository. + +.DESCRIPTION + MSBuild keeps stale assemblies, generated files and NuGet asset lists in bin/ and obj/. Documentation and + packaging tooling also creates repository-level artifact folders. When a project is renamed, a package + is downgraded or a source generator changes its output, those leftovers are what make a build fail or succeed + for the wrong reason. Deleting them forces the next build to start from source. + + The scan is anchored to the repository root - the parent of this script's directory - not to the current + working directory, so it deletes the same set no matter where you run it from. + + Generated XML documentation files are identified by matching each src project AssemblyName to an XML file + beside its project file. Authored XML files with other names are left untouched. + +.PARAMETER WhatIf + List the folders and files that would be deleted without deleting anything. + +.EXAMPLE + pwsh -File scripts/clean-build-artifacts.ps1 + +.EXAMPLE + pwsh -File scripts/clean-build-artifacts.ps1 -WhatIf +#> +[CmdletBinding(SupportsShouldProcess = $true)] +param() + +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path -Parent $PSScriptRoot + +Write-Output "Cleaning build artifacts under $repositoryRoot..." + +$additionalArtifactDirectories = + 'artifacts', + 'docs/api', + 'docs/_site', + 'tests/package-consumption/.packages' | + ForEach-Object { Get-Item -LiteralPath (Join-Path $repositoryRoot $_) -Force -ErrorAction SilentlyContinue } | + Where-Object { $_ -is [System.IO.DirectoryInfo] } + +# -Force so that hidden or system directories are enumerated too; .git is skipped because it never holds +# build output and walking it is pure cost. +$artifactDirectories = + @($additionalArtifactDirectories) + + @(Get-ChildItem -LiteralPath $repositoryRoot -Directory -Recurse -Force | + Where-Object { $_.Name -in 'bin', 'obj' -and $_.FullName -notmatch '(^|\\|/)\.git(\\|/)' }) + +$documentationFiles = + Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src') -Filter '*.csproj' -File -Recurse | + ForEach-Object { + [xml] $project = Get-Content -LiteralPath $_.FullName + $assemblyName = $project.Project.PropertyGroup.AssemblyName | Select-Object -First 1 + + if ($assemblyName) + { + $documentationFilePath = Join-Path $_.DirectoryName "$assemblyName.xml" + Get-Item -LiteralPath $documentationFilePath -Force -ErrorAction SilentlyContinue + } + } | + Where-Object { $_ -is [System.IO.FileInfo] } + +$deletedDirectories = 0 +$deletedFiles = 0 + +foreach ($directory in $artifactDirectories) +{ + # Already removed as part of an ancestor that matched earlier in the enumeration. + if (-not (Test-Path -LiteralPath $directory.FullName)) + { + continue + } + + if ($PSCmdlet.ShouldProcess($directory.FullName, 'Delete folder')) + { + Write-Output "Deleting folder: $($directory.FullName)" + + Remove-Item -LiteralPath $directory.FullName -Recurse -Force + + $deletedDirectories++ + } +} + +foreach ($file in $documentationFiles) +{ + if ($PSCmdlet.ShouldProcess($file.FullName, 'Delete generated documentation file')) + { + Write-Output "Deleting generated documentation file: $($file.FullName)" + + Remove-Item -LiteralPath $file.FullName -Force + + $deletedFiles++ + } +} + +Write-Output "Done. Deleted $deletedDirectories folder(s) and $deletedFiles generated documentation file(s)." + +exit 0 diff --git a/scripts/clean.cmd b/scripts/clean.cmd deleted file mode 100644 index a0a3709..0000000 --- a/scripts/clean.cmd +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -rem Removes all bin/ and obj/ folders in the repository. -cd /d "%~dp0.." || exit /b 1 -for /d /r . %%d in (bin obj) do @if exist "%%d" rd /s /q "%%d" \ No newline at end of file diff --git a/scripts/clean.sh b/scripts/clean.sh deleted file mode 100755 index 47c0e63..0000000 --- a/scripts/clean.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env sh -# Removes all bin/ and obj/ folders in the repository. -set -eu -cd "$(dirname "$0")/.." -find . -type d \( -name bin -o -name obj \) -prune -exec rm -rf {} + diff --git a/scripts/extract-release-notes.ps1 b/scripts/extract-release-notes.ps1 new file mode 100644 index 0000000..be2fb37 --- /dev/null +++ b/scripts/extract-release-notes.ps1 @@ -0,0 +1,106 @@ +<# +.SYNOPSIS + Validates the CHANGELOG entry for a release and writes its section to release-notes.md. + +.DESCRIPTION + Shared by the CI publish and GitHub-release jobs, so the notes attached to a release and the checks that + gate the publication come from one place. + + The CHANGELOG must contain exactly one dated `## [x.y.z] - YYYY-MM-DD` heading for the version, with no + TBD placeholder and a non-empty body. Those three failures all mean the same thing - a release was tagged + before its changelog entry was finished - and it is much cheaper to fail here than to publish three + immutable packages pointing at an empty section. + + On failure the script emits a GitHub Actions ::error annotation and exits non-zero. + +.PARAMETER Version + The version to extract, without the leading "v" - for example 1.0.0. + +.PARAMETER OutputFile + Where to write the extracted section. Defaults to release-notes.md in the repository root, which is what + the workflow attaches to the GitHub release. + +.EXAMPLE + pwsh -File scripts/extract-release-notes.ps1 -Version 1.0.0 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [String] $Version, + + [String] $OutputFile +) + +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$changelog = Join-Path $repositoryRoot 'CHANGELOG.md' + +if (-not $OutputFile) +{ + $OutputFile = Join-Path $repositoryRoot 'release-notes.md' +} + +if (-not (Test-Path $changelog)) +{ + Write-Host "::error title=Missing changelog::$changelog does not exist." + + exit 1 +} + +$lines = Get-Content -Path $changelog -Encoding utf8 + +# --- 1. Exactly one dated heading, and no TBD --------------------------------------------------------- + +$escapedVersion = [Regex]::Escape($Version) +$datedHeading = "^## \[$escapedVersion\] - \d{4}-\d{2}-\d{2}$" +$anyHeadingForVersion = "^## \[$escapedVersion\]" + +$datedHeadingCount = @($lines | Where-Object { $_ -match $datedHeading }).Count +$hasTbd = @($lines | Where-Object { $_ -match "^## \[$escapedVersion\] - TBD" }).Count -gt 0 + +if ($datedHeadingCount -ne 1 -or $hasTbd) +{ + Write-Host "::error title=Invalid changelog::Expected exactly one dated heading for $Version in CHANGELOG.md, with no TBD. Found $datedHeadingCount dated heading(s)$(if ($hasTbd) { ' and a TBD placeholder' })." + + exit 1 +} + +# --- 2. The section body ------------------------------------------------------------------------------ + +$section = New-Object System.Collections.Generic.List[String] +$inSection = $false + +foreach ($line in $lines) +{ + if ($line -match $anyHeadingForVersion) + { + $inSection = $true + + continue + } + + # The next release heading of any version ends this section. + if ($inSection -and ($line -match '^## \[')) + { + break + } + + if ($inSection) + { + $section.Add($line) + } +} + +if (-not ($section | Where-Object { $_.Trim() })) +{ + Write-Host "::error title=Empty changelog section::Release notes for $Version are empty." + + exit 1 +} + +Set-Content -Path $OutputFile -Value $section -Encoding utf8 + +Write-Host "Wrote the CHANGELOG section for $Version to $OutputFile ($($section.Count) line(s))." + +exit 0 diff --git a/scripts/extract-release-notes.sh b/scripts/extract-release-notes.sh deleted file mode 100755 index c19280e..0000000 --- a/scripts/extract-release-notes.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env sh -# Validates the CHANGELOG entry for a release and writes its section to release-notes.md. -# Shared by the CI publish and GitHub-release jobs (one source of truth). Takes the version (for -# example 1.0.0) as $1 and requires exactly one dated `## [x.y.z] - YYYY-MM-DD` heading, with no TBD -# and a non-empty section; on failure it emits the ::error annotation and exits non-zero. -set -eu -cd "$(dirname "$0")/.." - -version="$1" -escaped_version="$(printf '%s' "${version}" | sed 's/\./\\./g')" - -heading_count="$(grep -Ec "^## \[${escaped_version}\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$" CHANGELOG.md || true)" -if [ "${heading_count}" -ne 1 ] || grep -Fq "## [${version}] - TBD" CHANGELOG.md; then - echo "::error title=Invalid changelog::Expected one dated heading for ${version}, with no TBD." - exit 1 -fi - -awk -v ver="${version}" ' - $0 ~ "^## \\[" ver "\\]" { found = 1; next } - found && /^## \[/ { exit } - found { print } -' CHANGELOG.md > release-notes.md - -if ! grep -q '[^[:space:]]' release-notes.md; then - echo "::error title=Empty changelog section::Release notes for ${version} are empty." - exit 1 -fi diff --git a/scripts/formatCode.cmd b/scripts/formatCode.cmd deleted file mode 100644 index c65d8c9..0000000 --- a/scripts/formatCode.cmd +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -rem Formats the whole repository: C# whitespace via dotnet format, XAML via XamlStyler. -rem (Semantic style and analyzer rules are enforced by the build itself; dotnet format's -rem full analyzer mode is unreliable against multi-targeted projects.) -rem Always operates on the repository root, regardless of the caller's working directory. -cd /d "%~dp0.." || exit /b 1 -dotnet tool restore --configfile NuGet.config || exit /b 1 -dotnet format whitespace ResXLocalization.slnx || exit /b 1 -dotnet xstyler --recursive --directory src || exit /b 1 -dotnet xstyler --recursive --directory samples || exit /b 1 diff --git a/scripts/formatCode.sh b/scripts/formatCode.sh deleted file mode 100755 index 1656959..0000000 --- a/scripts/formatCode.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env sh -# Formats the repository on non-Windows hosts: C# whitespace via dotnet format, XAML via XamlStyler. -# (Semantic style and analyzer rules are enforced by the build itself; dotnet format's full -# analyzer mode is unreliable against multi-targeted projects.) -# Uses the non-Windows solution filter because the WPF projects only load on Windows. -set -eu -cd "$(dirname "$0")/.." -dotnet tool restore --configfile NuGet.config -dotnet format whitespace ResXLocalization.NonWindows.slnf -dotnet xstyler --recursive --directory src -dotnet xstyler --recursive --directory samples diff --git a/scripts/tidy-code.ps1 b/scripts/tidy-code.ps1 new file mode 100644 index 0000000..680a96a --- /dev/null +++ b/scripts/tidy-code.ps1 @@ -0,0 +1,208 @@ +<# +.SYNOPSIS + Applies this repository's formatting: C# whitespace, XAML layout and line endings. + +.DESCRIPTION + Three concerns, three tools, no overlap between them: + + C# whitespace indentation, blank lines, wrapping dotnet format whitespace + XAML layout attribute placement and ordering XamlStyler (xstyler) + line endings LF everywhere, per .gitattributes this script + + Semantic style and the analyzer rules are not here: the build enforces them itself, through + EnforceCodeStyleInBuild and TreatWarningsAsErrors. + + Line endings come last because XamlStyler cannot produce them. It always writes the host OS's + newline and has no setting for it, so on Windows it turns every XAML file it touches into CRLF. + Git stores LF regardless, but the working tree would keep reporting those files as modified with an + empty `git diff`. Rewriting them to LF here removes that. On Linux and macOS there is nothing to do. + + Needs the local tools: run `dotnet tool restore` once per clone, or let this script do it. + +.PARAMETER Check + Report whether the tree is already formatted, and exit non-zero if it is not. This is what CI runs. + + It still WRITES. XamlStyler has no check mode that is meaningful here: on Windows its passive check + fails on the LF files this repository stores, whatever their layout, because the tool compares + against its own CRLF output. So the honest question is not "does each tool approve?" but "does + formatting the tree change it?" - which is what this asks, and which gives the same answer on every + OS. Do not point -Check at a working tree you are not ready to have formatted. + +.EXAMPLE + pwsh -File scripts/tidy-code.ps1 + +.EXAMPLE + pwsh -File scripts/tidy-code.ps1 -Check +#> + +[CmdletBinding()] +param( + [Switch] $Check +) + +$ErrorActionPreference = 'Stop' + +# scripts/ - the repository root is one level up. +$repositoryRoot = Split-Path -Parent $PSScriptRoot + +# Windows PowerShell 5.1 has no $IsWindows variable, and an undefined variable is $null - so testing +# $IsWindows alone reports "not Windows" on the one host that is always Windows. The version test comes +# first for that reason. +$onWindows = $PSVersionTable.PSVersion.Major -lt 6 -or $IsWindows + +# The WPF projects only load on Windows; elsewhere the solution filter leaves them out. +$solution = if ($onWindows) { 'ResXLocalization.slnx' } else { 'ResXLocalization.NonWindows.slnf' } + +$failures = New-Object System.Collections.Generic.List[String] + +function Invoke-Tool { + <# + Runs `dotnet ...` and returns everything it printed. The caller decides success from $LASTEXITCODE. + + $ErrorActionPreference is deliberately relaxed for the call. With it at 'Stop', PowerShell turns + anything a native program writes to stderr into a terminating error, so a harmless warning would + abort the whole script with NativeCommandError. Exit codes decide success here, not stderr. + #> + param([Parameter(Mandatory)] [String[]] $Arguments) + + $previous = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { return (& dotnet @Arguments 2>&1 | Out-String) } + finally { $ErrorActionPreference = $previous } +} + +function Get-Fingerprint { + # A hash over the content of every file the tools touch, used by -Check to tell whether formatting + # changed anything. + # + # The list comes from git, not from Get-ChildItem: build output under obj/ and bin/ is on disk but + # ignored, and hashing it would make the check fail on files nothing formatted. + $patterns = @('*.cs', '*.xaml', '*.axaml', '*.resx') + $relativePaths = & git -C $repositoryRoot ls-files --cached --others --exclude-standard -- $patterns 2>$null | + Where-Object { $_ } | + Sort-Object + + # Fail loudly rather than hashing nothing. git's stderr goes to $null above, so a git that fails + # returns no paths instead of an error - and two hashes of an empty stream compare equal, which + # would report a formatted tree without having looked at a single file. This is CI's only + # formatting gate; it must not be able to pass by accident. + if (-not $relativePaths) { + throw 'tidy-code: git listed no files. Is this a git repository, and is git on PATH?' + } + + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $accumulator = New-Object System.IO.MemoryStream + foreach ($relativePath in $relativePaths) { + $fullPath = Join-Path $repositoryRoot $relativePath + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { continue } + + # The path goes into the hash too, so that adding or removing a file is a change. + $pathBytes = [System.Text.Encoding]::UTF8.GetBytes($relativePath) + $accumulator.Write($pathBytes, 0, $pathBytes.Length) + + $bytes = [System.IO.File]::ReadAllBytes($fullPath) + $accumulator.Write($bytes, 0, $bytes.Length) + } + + return [System.BitConverter]::ToString($sha.ComputeHash($accumulator.ToArray())) + } + finally { $sha.Dispose() } +} + +# --- C# whitespace -------------------------------------------------------------------------------- + +function Invoke-CSharpFormat { + # No --no-restore: this has to work on a fresh clone. When the packages are already restored - + # locally after a build, and in CI after the restore step - it costs little. + $output = Invoke-Tool -Arguments @('format', 'whitespace', $solution) + if ($LASTEXITCODE -ne 0) { $failures.Add("dotnet format whitespace:`n$output") } +} + +# --- XAML layout ---------------------------------------------------------------------------------- + +function Invoke-XamlFormat { + foreach ($directory in @('src', 'samples')) { + $target = Join-Path $repositoryRoot $directory + if (-not (Test-Path -LiteralPath $target)) { continue } + + $output = Invoke-Tool -Arguments @('xstyler', '--recursive', '--directory', $target) + if ($LASTEXITCODE -ne 0) { $failures.Add("dotnet xstyler ($directory):`n$output") } + } +} + +# --- Line endings --------------------------------------------------------------------------------- +# Always last: XamlStyler above writes the host newline and cannot be told otherwise. + +function Invoke-LineEndingFix { + # Byte-level, so the encoding and any byte order mark survive. A file that needs no change is not + # written at all, so unchanged files keep their timestamp. + $patterns = @('*.xaml', '*.axaml', '*.resx') + $relativePaths = @(& git -C $repositoryRoot ls-files -- $patterns 2>$null) + + $rewritten = New-Object System.Collections.Generic.List[String] + + foreach ($relativePath in $relativePaths) { + if ([String]::IsNullOrWhiteSpace($relativePath)) { continue } + + $fullPath = Join-Path $repositoryRoot $relativePath + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { continue } + + $bytes = [System.IO.File]::ReadAllBytes($fullPath) + $output = New-Object System.Collections.Generic.List[Byte] + + # Drop every CR that is directly followed by LF. A lone CR is left alone, and a UTF-16 file + # never matches this pattern - its CR is followed by a NUL - so neither can be corrupted here. + for ($index = 0; $index -lt $bytes.Length; $index++) { + if ($bytes[$index] -eq 13 -and ($index + 1) -lt $bytes.Length -and $bytes[$index + 1] -eq 10) { + continue + } + + $output.Add($bytes[$index]) + } + + if ($output.Count -ne $bytes.Length) { + [System.IO.File]::WriteAllBytes($fullPath, $output.ToArray()) + $rewritten.Add($relativePath) + } + } + + # Rewriting a file leaves git's cached stat information stale, which on its own keeps the file + # listed as modified by `git status`. Clear that, but only where doing so cannot stage anything: + # the file's blob must already equal what the index holds. A file with real changes is left alone, + # so this never stages work on the developer's behalf. + foreach ($relativePath in $rewritten) { + $indexBlob = (& git -C $repositoryRoot ls-files -s -- $relativePath) -split '\s+' | Select-Object -Index 1 + $fileBlob = & git -C $repositoryRoot hash-object --path $relativePath -- (Join-Path $repositoryRoot $relativePath) + + if ($indexBlob -and $fileBlob -and $indexBlob -eq $fileBlob) { + & git -C $repositoryRoot add -- $relativePath + } + } +} + +# --- Run ------------------------------------------------------------------------------------------ + +$output = Invoke-Tool -Arguments @('tool', 'restore', '--configfile', (Join-Path $repositoryRoot 'NuGet.config')) +if ($LASTEXITCODE -ne 0) { + Write-Output "tidy-code: dotnet tool restore:`n$output" + exit 1 +} + +$before = if ($Check) { Get-Fingerprint } else { $null } + +Invoke-CSharpFormat +Invoke-XamlFormat +Invoke-LineEndingFix + +if ($Check -and -not $failures.Count -and (Get-Fingerprint) -ne $before) { + $failures.Add('the tree is not formatted. Run: pwsh -File scripts/tidy-code.ps1') +} + +if (-not $failures.Count) { + Write-Output "tidy-code: repository $(if ($Check) { 'checked' } else { 'formatted' })." +} +else { + $failures | ForEach-Object { Write-Output "tidy-code: $_" } + exit 1 +} From 3c9a8deec2a886ad7c8e5a269c5f8b60b11cc78b Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Wed, 2 Sep 2026 10:22:46 +0200 Subject: [PATCH 2/3] style: renormalize line endings to LF Co-Authored-By: Claude Opus 5 --- Directory.Packages.props | 74 ++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index bb28d97..cefd3db 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,37 +1,37 @@ - - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From dc083735440adf6e1910aa2742b0d260347cb723 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Wed, 2 Sep 2026 10:23:13 +0200 Subject: [PATCH 3/3] chore: add the renormalization commit to .git-blame-ignore-revs Co-Authored-By: Claude Opus 5 --- .git-blame-ignore-revs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..74e05b7 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,14 @@ +# Commits that rewrote lines across the repository without changing what the code does. +# `git blame` skips them, so it points at whoever wrote the logic rather than at the tool. +# +# GitHub reads this file automatically. Locally you have to opt in, once per clone: +# +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# +# The SHAs below are load-bearing, and git does NOT warn about one it cannot resolve: it skips the +# entry. "Rebase and merge" and "Squash and merge" rewrite every SHA on a branch, so merge a branch +# that adds an entry here with a MERGE COMMIT. CI's lint job fails if a revision stops resolving. + +# style: renormalize line endings to LF +# Line endings only. +3c9a8deec2a886ad7c8e5a269c5f8b60b11cc78b