From 66fa5fc7113fbb2dd3d080e35cadb2304777d544 Mon Sep 17 00:00:00 2001 From: Eyal Alon <3756641+soyeyalon@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:30:10 +0200 Subject: [PATCH 1/9] build: store every text file with LF --- .gitattributes | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/.gitattributes b/.gitattributes index 3846ac9..b8653f8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,28 +1,12 @@ -# Set default behavior to automatically normalize line endings. -* text=auto +# Every text file is stored and checked out with LF, whatever platform it is checked out on, so a file +# reads the same everywhere and a generator writing one produces the same bytes on any machine. Declaring +# the default rather than a list of extensions means a file type nobody has added yet is already right. +* text=auto eol=lf -# Explicitly declare text files you want to always be normalized and converted -# to native line endings on checkout. -*.cs text eol=lf -*.csx text eol=lf -*.razor text eol=lf -*.xaml text eol=lf -*.xml text eol=lf -*.config text eol=lf -*.json text eol=lf -*.yml text eol=lf -*.yaml text eol=lf -*.md text eol=lf -*.txt text eol=lf -*.props text eol=lf -*.targets text eol=lf -*.csproj text eol=lf -*.sln text eol=lf - -# Declare files that will always have CRLF line endings on checkout. +# The exception, and the only one: cmd.exe parses a batch file line by line as it runs it, and mishandles +# a label or a goto that ends in LF alone. *.bat text eol=crlf *.cmd text eol=crlf -*.ps1 text eol=crlf # Denote all files that are truly binary and should not be modified. *.png binary @@ -51,3 +35,5 @@ *.pptx binary *.snk binary *.pfx binary +*.mp4 binary +*.wav binary From d8391b0e446a597894dc275d5c77bf93cc5abcc9 Mon Sep 17 00:00:00 2001 From: Eyal Alon <3756641+soyeyalon@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:37:55 +0200 Subject: [PATCH 2/9] build(locali): link the package to its source and changelog --- CHANGELOG.md | 11 +++++++++++ Directory.Build.props | 5 +++++ src/Forma.Locali/Forma.Locali.csproj | 7 ++++++- 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b9822e3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +Each release of Locali, newest first. An entry is dated when its release is cut, and reads *Unreleased* until then. +The releases before `1.0.0-preview.11` are recorded by their [tags](https://github.com/FormaUI/Locali/tags) alone. + +## `1.0.0-preview.11` + +*Unreleased* + +- The package links each of its assemblies to the commit it was built from through Source Link, carries its symbols + in a `.snupkg` beside it, and names this entry as its release notes. diff --git a/Directory.Build.props b/Directory.Build.props index e30d521..d503e3e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -15,4 +15,9 @@ true + + + true + + diff --git a/src/Forma.Locali/Forma.Locali.csproj b/src/Forma.Locali/Forma.Locali.csproj index 827e60f..8ec8cc2 100644 --- a/src/Forma.Locali/Forma.Locali.csproj +++ b/src/Forma.Locali/Forma.Locali.csproj @@ -15,14 +15,19 @@ true locali formaui-net.Locali - 1.0.0-preview.10 + 1.0.0-preview.11 Eyal Alon A linter for Forma.Framework.Localization: reads locale files against the runtime's rules and every IStringLocale callsite against what the runtime will do with it. localization;linter;blazor;forma https://github.com/FormaUI/Locali https://github.com/FormaUI/Locali + true Apache-2.0 README.md + https://github.com/FormaUI/Locali/blob/v$(Version)/CHANGELOG.md#$(Version.Replace('.', '')) + true + snupkg + true From d78e7553c36d3db4258572d3debd6449b262a103 Mon Sep 17 00:00:00 2001 From: Eyal Alon <3756641+soyeyalon@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:50:13 +0200 Subject: [PATCH 3/9] build(locali): verify the package against its inventory --- build.ps1 | 133 ++++++++++++++++++++++++++++- src/Forma.Locali/package-files.txt | 43 ++++++++++ 2 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/Forma.Locali/package-files.txt diff --git a/build.ps1 b/build.ps1 index 21916d5..b93077f 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,16 +1,26 @@ <# .SYNOPSIS -Builds the solution, runs its tests, and checks its formatting. +Runs the fast gate: builds the solution, runs its tests, and checks its formatting. With -Full, runs the full gate, +which also packs the tool and verifies the package as a consumer receives it. .PARAMETER Clean Removes every build output first, so the build starts from nothing. +.PARAMETER Full +Packs the tool into artifacts/packages, checks the package against src/Forma.Locali/package-files.txt and its symbol +package against the .pdb files listed there, installs it from a local feed into a folder outside the checkout, and runs +it over a sample project, clean and with a diagnostic. + .EXAMPLE ./build.ps1 -Clean + +.EXAMPLE +./build.ps1 -Full #> [CmdletBinding()] param( - [switch] $Clean + [switch] $Clean, + [switch] $Full ) Set-StrictMode -Version Latest @@ -49,3 +59,122 @@ dotnet format $solution --verify-no-changes if ($LASTEXITCODE -ne 0) { throw 'dotnet format found changes to make; run: dotnet format Forma.Locali.slnx' } + +if (-not $Full) { + return +} + +$tool = Join-Path $root 'src/Forma.Locali/Forma.Locali.csproj' +$inventory = Join-Path $root 'src/Forma.Locali/package-files.txt' +$packages = Join-Path $artifacts 'packages' + +[xml] $toolProject = Get-Content $tool +$id = $toolProject.SelectSingleNode('//PropertyGroup/PackageId').InnerText +$version = $toolProject.SelectSingleNode('//PropertyGroup/Version').InnerText + +Write-Host "Packing $id $version" +if (Test-Path $packages) { + Remove-Item $packages -Recurse -Force +} +dotnet pack $tool -c Release -o $packages -nologo +if ($LASTEXITCODE -ne 0) { + throw 'dotnet pack failed.' +} + +# The files a package holds, leaving out its manifest and the parts of its container, which are NuGet's own. +function Get-PackageFile([string] $Path) { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($Path) + try { + @($archive.Entries | + Where-Object { $_.Name -and $_.FullName -notmatch '^(_rels/|package/|\[Content_Types\]\.xml$)' -and $_.FullName -notlike '*.nuspec' } | + ForEach-Object FullName) + } + finally { + $archive.Dispose() + } +} + +Write-Host 'Checking the package against its inventory' +$listed = @(Get-Content $inventory | Where-Object { $_ -and -not $_.StartsWith('#') }) +$held = Get-PackageFile (Join-Path $packages "$id.$version.nupkg") +$missing = @($listed | Where-Object { $_ -cnotin $held }) +$unlisted = @($held | Where-Object { $_ -cnotin $listed }) +if ($missing.Count -gt 0 -or $unlisted.Count -gt 0) { + throw "The package doesn't match $inventory. Missing from the package: $($missing -join ', '). Held but not listed: $($unlisted -join ', ')." +} + +$symbols = Get-PackageFile (Join-Path $packages "$id.$version.snupkg") +$listedSymbols = @($listed | Where-Object { $_ -like '*.pdb' }) +if (@($symbols | Where-Object { $_ -cnotin $listedSymbols }).Count -gt 0 -or @($listedSymbols | Where-Object { $_ -cnotin $symbols }).Count -gt 0) { + throw "The symbol package holds $($symbols -join ', '), where $inventory lists $($listedSymbols -join ', ')." +} + +# A folder outside the checkout, with a feed of the packages just packed, and a package cache and a CLI home of its own, +# whose local-tool cache records where each installed tool lives, so the tool run is the one packed and nothing of the +# checkout's or the machine's reaches it. +Write-Host 'Installing the package outside the checkout and running it' +$consumer = Join-Path ([System.IO.Path]::GetTempPath()) "locali-package-$([guid]::NewGuid().ToString('N'))" +$sample = Join-Path $consumer 'Sample' +$previousPackages = $env:NUGET_PACKAGES +$previousCliHome = $env:DOTNET_CLI_HOME +New-Item -ItemType Directory -Path $sample | Out-Null + +try { + $env:NUGET_PACKAGES = Join-Path $consumer 'packages' + $env:DOTNET_CLI_HOME = $consumer + Set-Content -Path (Join-Path $consumer 'nuget.config') -Value @" + + + + + + + + +"@ + Set-Content -Path (Join-Path $sample 'Sample.csproj') -Value @' + + + net10.0 + + +'@ + Set-Content -Path (Join-Path $sample 'Widget.cs') -Value 'namespace Sample; public sealed class Widget;' + + Push-Location $consumer + try { + dotnet new tool-manifest | Out-Null + dotnet tool install $id --version $version | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Installing $id $version from $packages failed." + } + + $output = dotnet tool run locali --path (Join-Path $sample 'Sample.csproj') 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + throw "The installed tool exited $LASTEXITCODE over a clean sample, where it exits 0: $output" + } + + # A culture-named file without its $localization entry is the one a localization file must carry. + New-Item -ItemType Directory -Path (Join-Path $sample '.locales') | Out-Null + Set-Content -Path (Join-Path $sample '.locales/he.json') -Value '{ "Today": "Today" }' + + $output = dotnet tool run locali --path (Join-Path $sample 'Sample.csproj') 2>&1 | Out-String + if ($LASTEXITCODE -ne 1 -or $output -notmatch 'LOC0101') { + throw "The installed tool exited $LASTEXITCODE over a sample with a file lacking `$localization, where it exits 1 reporting LOC0101: $output" + } + } + finally { + Pop-Location + } +} +finally { + $env:NUGET_PACKAGES = $previousPackages + $env:DOTNET_CLI_HOME = $previousCliHome + Remove-Item $consumer -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host "Verified $id $version" + +# The sample's run with a diagnostic left its exit code of 1 as the last one, which would otherwise be the script's. +exit 0 diff --git a/src/Forma.Locali/package-files.txt b/src/Forma.Locali/package-files.txt new file mode 100644 index 0000000..4a6fc54 --- /dev/null +++ b/src/Forma.Locali/package-files.txt @@ -0,0 +1,43 @@ +# Every file the formaui-net.Locali package holds. ./build.ps1 -Full fails on a file the package lacks or one it +# holds beyond this list, and checks that its symbol package holds exactly the .pdb files listed here. +LICENSE +NOTICE +README.md +tools/net11.0/any/DotnetToolSettings.xml +tools/net11.0/any/Forma.Framework.Culture.dll +tools/net11.0/any/Forma.Framework.Culture.pdb +tools/net11.0/any/Forma.Framework.Events.dll +tools/net11.0/any/Forma.Framework.Events.pdb +tools/net11.0/any/Forma.Framework.Localization.Json.dll +tools/net11.0/any/Forma.Framework.Localization.Json.pdb +tools/net11.0/any/Forma.Framework.Localization.dll +tools/net11.0/any/Forma.Framework.Localization.pdb +tools/net11.0/any/Forma.Locali.Engine.dll +tools/net11.0/any/Forma.Locali.Engine.pdb +tools/net11.0/any/Microsoft.Build.Locator.dll +tools/net11.0/any/Microsoft.Extensions.Configuration.Binder.dll +tools/net11.0/any/Microsoft.Extensions.Configuration.CommandLine.dll +tools/net11.0/any/Microsoft.Extensions.Configuration.EnvironmentVariables.dll +tools/net11.0/any/Microsoft.Extensions.Configuration.FileExtensions.dll +tools/net11.0/any/Microsoft.Extensions.Configuration.Json.dll +tools/net11.0/any/Microsoft.Extensions.Configuration.UserSecrets.dll +tools/net11.0/any/Microsoft.Extensions.Configuration.dll +tools/net11.0/any/Microsoft.Extensions.DependencyInjection.dll +tools/net11.0/any/Microsoft.Extensions.Diagnostics.dll +tools/net11.0/any/Microsoft.Extensions.FileProviders.Physical.dll +tools/net11.0/any/Microsoft.Extensions.FileSystemGlobbing.dll +tools/net11.0/any/Microsoft.Extensions.Hosting.dll +tools/net11.0/any/Microsoft.Extensions.Logging.Configuration.dll +tools/net11.0/any/Microsoft.Extensions.Logging.Console.dll +tools/net11.0/any/Microsoft.Extensions.Logging.Debug.dll +tools/net11.0/any/Microsoft.Extensions.Logging.EventLog.dll +tools/net11.0/any/Microsoft.Extensions.Logging.EventSource.dll +tools/net11.0/any/Microsoft.Extensions.Logging.dll +tools/net11.0/any/Microsoft.Extensions.Options.ConfigurationExtensions.dll +tools/net11.0/any/System.Diagnostics.EventLog.dll +tools/net11.0/any/locali.deps.json +tools/net11.0/any/locali.dll +tools/net11.0/any/locali.pdb +tools/net11.0/any/locali.runtimeconfig.json +tools/net11.0/any/runtimes/win/lib/net10.0/System.Diagnostics.EventLog.Messages.dll +tools/net11.0/any/runtimes/win/lib/net10.0/System.Diagnostics.EventLog.dll From 2857b8586ae70ed0e30f34d1fd6520b0deee2402 Mon Sep 17 00:00:00 2001 From: Eyal Alon <3756641+soyeyalon@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:50:50 +0200 Subject: [PATCH 4/9] ci(locali): run the fast gate on every push but main's --- .github/workflows/ci.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45beec1..da3a571 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,16 +1,18 @@ -# Runs the gate a person runs, then lints Forma, which lints clean, so anything reported there is a regression. +# Runs the fast gate a person runs, then lints Forma, which lints clean, so anything reported there is a regression. It +# runs on every push but main's, which promotion fast-forwards to a commit candidate's full gate passed, and on every +# pull request. name: CI on: push: - branches: [main] + branches-ignore: [main] pull_request: - branches: [main] workflow_dispatch: +# A newer push to a pull request supersedes the older run; every commit on a branch keeps its own result. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read From c449d09b4baab9164f642015896b34e7ea0c9e8b Mon Sep 17 00:00:00 2001 From: Eyal Alon <3756641+soyeyalon@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:51:43 +0200 Subject: [PATCH 5/9] ci(locali): run the full gate on candidate --- .github/workflows/full.yml | 71 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/full.yml diff --git a/.github/workflows/full.yml b/.github/workflows/full.yml new file mode 100644 index 0000000..133faa1 --- /dev/null +++ b/.github/workflows/full.yml @@ -0,0 +1,71 @@ +# Runs the full gate on the candidate: the fast gate on Linux and Windows, the package packed and verified as a +# consumer receives it, and Forma linted. Its passing run on a push to candidate is the one the promotion and release +# checks look for on the exact commit, and the packages it keeps are the ones the release publishes. +name: Full + +on: + push: + branches: [candidate] + workflow_dispatch: + +# A candidate superseded on candidate before its run finished is no longer the one to promote. +concurrency: + group: full-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + gate: + name: gate (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + # The solution references Forma's runtime beside it, so both repositories are checked out side by side, Forma at the branch its runtime is developed on. + - uses: actions/checkout@v7 + with: + path: Forma.Locali + + - uses: actions/checkout@v7 + with: + repository: FormaUI/Forma + ref: dev + path: formaui-net + + # The SDK Forma pins, which is the one the linter is built and run with. + - name: Set up .NET + uses: actions/setup-dotnet@v6 + with: + global-json-file: formaui-net/global.json + + # The package is packed and verified once, on Linux, and the release publishes that package. + - name: Run the full gate + if: runner.os == 'Linux' + working-directory: Forma.Locali + run: pwsh ./build.ps1 -Full + + - name: Run the gate + if: runner.os != 'Linux' + working-directory: Forma.Locali + run: pwsh ./build.ps1 + + - name: Lint Forma + working-directory: Forma.Locali + run: | + dotnet restore ../formaui-net/Forma.slnx + dotnet run --project src/Forma.Locali --no-build -- --path ../formaui-net/Forma.slnx --exclude "*/Fixtures/*" + + - name: Keep the verified packages + if: runner.os == 'Linux' + uses: actions/upload-artifact@v7 + with: + name: packages + path: Forma.Locali/artifacts/packages/ + if-no-files-found: error + retention-days: 90 From 14b8e574f76c0fa00e62c30dd860b85124b95ff8 Mon Sep 17 00:00:00 2001 From: Eyal Alon <3756641+soyeyalon@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:52:44 +0200 Subject: [PATCH 6/9] ci(locali): promote and release through Versioning --- .github/workflows/promotion.yml | 30 +++++++++ .github/workflows/release.yml | 108 +++++++++++++++++++------------- 2 files changed, 94 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/promotion.yml diff --git a/.github/workflows/promotion.yml b/.github/workflows/promotion.yml new file mode 100644 index 0000000..3f963a8 --- /dev/null +++ b/.github/workflows/promotion.yml @@ -0,0 +1,30 @@ +# Holds every pull request into main to the promotion check of FormaUI/Versioning, at the release Locali follows: it +# comes from candidate, candidate's full gate passed on its head commit and still holds the packages it built, and the +# version it declares is new, with a dated changelog entry. +name: Promotion + +on: + pull_request: + branches: [main] + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + actions: read + pull-requests: read + +jobs: + promotion: + name: Promotion + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: FormaUI/Versioning/actions/promotion-check@68ff74cadbe94084384d29be93466713fd989b53 # v1.0.0-preview.1 + with: + gate-workflow: full.yml + version-command: "([xml] (Get-Content src/Forma.Locali/Forma.Locali.csproj)).SelectSingleNode('//PropertyGroup/Version').InnerText" + packages-artifact: packages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dab6b27..1269d28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,65 +1,51 @@ -# Publishes a release tag's package to nuget.org through Trusted Publishing, once the gate passes on the tagged commit. +# Publishes a release from its signed vX.Y.Z tag on main. The release check of FormaUI/Versioning, at the release Locali +# follows, holds the tag first; the package candidate's full gate built and verified is then attached to the tag's +# GitHub release with its digest, and published to nuget.org through trusted publishing. A failed publication runs again +# for the same tag. name: Release on: push: - tags: ['*.*.*'] - workflow_dispatch: - inputs: - tag: - description: The release tag to publish, such as 1.0.0-preview.10. - required: true + tags: ['v*'] + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false permissions: contents: read jobs: - publish: + release: + name: Release runs-on: ubuntu-latest permissions: - contents: read - # nuget.org exchanges the job's OIDC token for a key that lasts an hour. + # The release-packages step writes the tag's GitHub release. + contents: write + actions: read + pull-requests: read + # nuget.org exchanges the job's OIDC token for a key that lasts an hour, through its policy for this workflow. id-token: write - env: - RELEASE_TAG: ${{ inputs.tag || github.ref_name }} - steps: - # The solution references Forma's runtime beside it, so both repositories are checked out side by side, Forma at the branch its runtime is developed on. - uses: actions/checkout@v7 - with: - ref: ${{ env.RELEASE_TAG }} - path: Forma.Locali - - uses: actions/checkout@v7 + - name: Hold the tag to the release check + id: check + uses: FormaUI/Versioning/actions/release-check@68ff74cadbe94084384d29be93466713fd989b53 # v1.0.0-preview.1 with: - repository: FormaUI/Forma - ref: dev - path: formaui-net + gate-workflow: full.yml + version-command: "([xml] (Get-Content src/Forma.Locali/Forma.Locali.csproj)).SelectSingleNode('//PropertyGroup/Version').InnerText" + packages-artifact: packages - - name: Set up .NET - uses: actions/setup-dotnet@v6 + - name: Attach the release's packages + id: packages + uses: FormaUI/Versioning/actions/release-packages@68ff74cadbe94084384d29be93466713fd989b53 # v1.0.0-preview.1 with: - global-json-file: formaui-net/global.json - - - name: Check the tag names the project's version - working-directory: Forma.Locali - shell: pwsh - run: | - [xml] $project = Get-Content src/Forma.Locali/Forma.Locali.csproj - $version = $project.SelectSingleNode('//PropertyGroup/Version').InnerText -replace '\+.*$', '' - if ($version -ne $env:RELEASE_TAG) { - throw "The tag $env:RELEASE_TAG does not name the project's version, $version." - } - - - name: Run the gate - working-directory: Forma.Locali - run: pwsh ./build.ps1 - - - name: Pack - working-directory: Forma.Locali - run: dotnet pack src/Forma.Locali -c Release -o "${{ runner.temp }}/package" -nologo + gate-run-id: ${{ steps.check.outputs.gate-run-id }} + packages-artifact: packages + name: Locali # The user is the nuget.org account that made the policy. - name: Get a nuget.org key @@ -68,5 +54,39 @@ jobs: with: user: soyeyalon - - name: Push to nuget.org - run: dotnet nuget push "${{ runner.temp }}/package/*.nupkg" --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json + # A package nuget.org holds already was published by an earlier run for this tag; pushing a package pushes its + # symbol package beside it. + - name: Publish to nuget.org + shell: pwsh + env: + NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} + PACKAGES: ${{ steps.packages.outputs.directory }} + run: | + Add-Type -AssemblyName System.IO.Compression.FileSystem + foreach ($package in Get-ChildItem -LiteralPath $env:PACKAGES -Filter '*.nupkg') { + $archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName) + try { + $entry = $archive.Entries | Where-Object { $_.FullName -notmatch '/' -and $_.Name -like '*.nuspec' } | Select-Object -First 1 + $reader = [System.IO.StreamReader]::new($entry.Open()) + try { + $metadata = ([xml] $reader.ReadToEnd()).package.metadata + } + finally { + $reader.Dispose() + } + } + finally { + $archive.Dispose() + } + + $index = Invoke-RestMethod "https://api.nuget.org/v3-flatcontainer/$($metadata.id.ToLowerInvariant())/index.json" -SkipHttpErrorCheck -StatusCodeVariable status + if ($status -eq 200 -and $index.versions -contains $metadata.version.ToLowerInvariant()) { + Write-Host "nuget.org already holds $($metadata.id) $($metadata.version)." + continue + } + + dotnet nuget push $package.FullName --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json + if ($LASTEXITCODE -ne 0) { + throw "Pushing $($package.Name) to nuget.org failed." + } + } From d98e6744378a6cc60da71d31b0601e0b94b0397d Mon Sep 17 00:00:00 2001 From: Eyal Alon <3756641+soyeyalon@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:54:05 +0200 Subject: [PATCH 7/9] build(locali): retire publish.ps1 --- CHANGELOG.md | 2 + README.md | 39 +++------ publish.ps1 | 217 --------------------------------------------------- 3 files changed, 14 insertions(+), 244 deletions(-) delete mode 100644 publish.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index b9822e3..0b927c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,3 +9,5 @@ The releases before `1.0.0-preview.11` are recorded by their [tags](https://gith - The package links each of its assemblies to the commit it was built from through Source Link, carries its symbols in a `.snupkg` beside it, and names this entry as its release notes. +- The package is published to nuget.org alone; the FormaUI organization's GitHub Packages feed receives no new + version. diff --git a/README.md b/README.md index 0232a7b..c9f54fc 100644 --- a/README.md +++ b/README.md @@ -290,38 +290,23 @@ clean, so anything a rule reports there is a false positive. It does not cover e callsite can take — nothing there passes a values collection whole — so those belong in unit tests. -### Publishing +### Releasing -`publish.ps1` releases a version: it commits the version to the project file, packs the tool in -Release, marks the commit with a signed annotated tag named for the version, pushes the commit -and the tag to the branch's upstream together, and pushes the package: +A release follows Versioning's +[releases](https://github.com/FormaUI/Versioning/blob/v1.0.0-preview.1/docs/releases.md): the +version and the changelog entry's date are set on `dev`, `candidate` fast-forwards to `dev`'s tip +so `full.yml` runs on it, a pull request from `candidate` into `main` passes the promotion check +and `main` fast-forwards to it, and a release signer tags `main`: ```powershell -./publish.ps1 -Source GitHub -Version 1.0.0-preview.5 # the organization's feed, with the GitHub CLI's token -./publish.ps1 -Source NuGet -Version 1.0.0-preview.5 # nuget.org, with the key in NUGET_API_KEY -./publish.ps1 -Source All -Version 1.0.0-preview.5 +git tag -s v1.0.0-preview.11 -m "Locali 1.0.0-preview.11" +git push origin v1.0.0-preview.11 ``` -The source is matched without regard to case. The GitHub CLI's token needs the `write:packages` -scope, which `gh auth refresh -h github.com -s write:packages` adds. Before changing anything the -script refuses a version any chosen feed already holds, a version whose tag marks another -commit, a branch behind its upstream, and a working tree with uncommitted changes unless `-Force` -is passed, so a package always matches a tagged, pushed commit. A run that stops after its release -commit or its tag picks up from them when run again with the same version. `-WhatIf` runs every -check and lists the commit, tag, pack and pushes a release would make, without making them. - -A pushed release tag runs `.github/workflows/release.yml`, which checks that the tag names the -project's version, runs `build.ps1` on the tagged commit, packs it, and pushes the package to -nuget.org through Trusted Publishing, with a key nuget.org issues for that run alone; a push -nuget.org refuses, a version it already holds among them, fails the run with nuget.org's reason. -The workflow can also be run by hand for a tag pushed before it existed: - -```powershell -gh workflow run release.yml -f tag=1.0.0-preview.10 -``` - -It reads the nuget.org user name from the repository secret `NUGET_USER`, and nuget.org's -trusted publishing policy names this repository and `release.yml`. +The tag's push runs `release.yml`, which holds the tag to the release check, attaches the package +`full.yml` verified to the tag's GitHub release with its digest, and publishes it to nuget.org +through trusted publishing, as `soyeyalon`, the account whose policy names this repository and +`release.yml`. A failed publication runs again for the same tag. ### Roslyn Is The SDK's Compiler diff --git a/publish.ps1 b/publish.ps1 deleted file mode 100644 index 1ea2ad7..0000000 --- a/publish.ps1 +++ /dev/null @@ -1,217 +0,0 @@ -<# -.SYNOPSIS -Releases a version of the linter: commits the version, tags it, pushes both, and publishes the package to the organization's GitHub feed, to nuget.org, or to both. - -.PARAMETER Source -Where to publish: GitHub, NuGet or All. Matched without regard to case. - -.PARAMETER Version -The version to release, which also names its tag. A version any chosen feed already holds, or whose tag marks another commit, is refused. - -.PARAMETER Force -Publishes from a working tree that has uncommitted changes. - -.EXAMPLE -./publish.ps1 -Source GitHub -Version 1.0.0-preview.5 - -.EXAMPLE -./publish.ps1 -Source All -Version 1.0.0-preview.5 -WhatIf - -Runs every check and lists the commit, tag, pack and pushes a release would make, without making them. -#> -[CmdletBinding(SupportsShouldProcess)] -param( - [Parameter(Mandatory)] - [ValidateSet('GitHub', 'NuGet', 'All')] - [string] $Source, - - [Parameter(Mandatory)] - [ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$', ErrorMessage = 'The version "{0}" is not a semantic version such as 1.0.0 or 1.0.0-preview.5.')] - [string] $Version, - - [switch] $Force -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$root = $PSScriptRoot -$project = Join-Path $root 'src/Forma.Locali/Forma.Locali.csproj' -$artifacts = Join-Path $root 'artifacts' -$organization = 'FormaUI' - -$feeds = @{ - GitHub = "https://nuget.pkg.github.com/$organization/index.json" - NuGet = 'https://api.nuget.org/v3/index.json' -} - -# The parameter matches without regard to case; every message spells a source the one way. -$Source = @('GitHub', 'NuGet', 'All') | Where-Object { $_ -ieq $Source } -$targets = if ($Source -eq 'All') { @('GitHub', 'NuGet') } else { @($Source) } - -function Get-GitHubCli { - $gh = (Get-Command gh -ErrorAction SilentlyContinue)?.Source ?? "$env:ProgramFiles\GitHub CLI\gh.exe" - if (-not (Test-Path $gh)) { - throw 'The GitHub CLI was not found; install it and run: gh auth login' - } - - return $gh -} - -# The GitHub CLI's own token publishes to the organization's feed once it carries the write:packages scope. -function Get-GitHubKey { - $gh = Get-GitHubCli - $status = & $gh auth status 2>&1 | Out-String - if ($status -notmatch 'write:packages') { - throw 'The GitHub CLI token lacks the write:packages scope; run: gh auth refresh -h github.com -s write:packages' - } - - $token = & $gh auth token - if (-not $token) { - throw 'The GitHub CLI handed back no token; run: gh auth login' - } - - return $token -} - -# nuget.org takes an API key made under the account's settings, read from NUGET_API_KEY. -function Get-NuGetKey { - if (-not $env:NUGET_API_KEY) { - throw 'Set NUGET_API_KEY to an API key from nuget.org before publishing there.' - } - - return $env:NUGET_API_KEY -} - -# The versions a feed already holds; a package the feed has never seen holds none. -function Get-PublishedVersions([string] $target, [string] $packageId) { - if ($target -eq 'GitHub') { - $gh = Get-GitHubCli - $output = & $gh api --paginate "orgs/$organization/packages/nuget/$packageId/versions" --jq '.[].name' 2>&1 - if ($LASTEXITCODE -eq 0) { - return @($output | ForEach-Object { "$_" }) - } - if (($output | Out-String) -match 'HTTP 404') { - return @() - } - - throw "Reading the versions GitHub holds failed: $($output | Out-String)" - } - - $response = Invoke-RestMethod "https://api.nuget.org/v3-flatcontainer/$($packageId.ToLowerInvariant())/index.json" -SkipHttpErrorCheck -StatusCodeVariable status - if ($status -eq 404) { - return @() - } - if ($status -ne 200) { - throw "Reading the versions nuget.org holds failed with HTTP $status." - } - - return @($response.versions) -} - -function Invoke-Git { - git -C $root @args - if ($LASTEXITCODE -ne 0) { - throw "git $($args -join ' ') failed." - } -} - -# A package is built from a commit, so a tree with changes on top of one is refused unless asked. -if ((git -C $root status --porcelain) -and -not $Force) { - throw 'The working tree has uncommitted changes; commit them, or pass -Force to publish anyway.' -} -if (git -C $root status --porcelain -- $project) { - throw 'The project file has uncommitted changes, and the release commit would carry them.' -} - -# The release commit and its tag are pushed to the branch's upstream, so the branch must have one and hold everything it has. -$upstream = git -C $root rev-parse --abbrev-ref '@{upstream}' 2> $null -if ($LASTEXITCODE -ne 0) { - throw 'The current branch tracks no upstream branch to push the release to.' -} -$branch = git -C $root symbolic-ref --short HEAD -$remote = git -C $root config "branch.$branch.remote" -$upstreamRef = git -C $root config "branch.$branch.merge" -Invoke-Git fetch --quiet --tags $remote -if ([int] (git -C $root rev-list --count 'HEAD..@{upstream}') -gt 0) { - throw 'The current branch is behind its upstream; pull before releasing.' -} - -[xml] $csproj = Get-Content $project -$packageId = $csproj.SelectSingleNode('//PropertyGroup/PackageId').InnerText -$currentVersion = $csproj.SelectSingleNode('//PropertyGroup/Version').InnerText - -# Feeds compare versions without their build metadata and without regard to case. -$feedVersion = $Version -replace '\+.*$', '' - -# A tag marks the commit a version was packed from, so an existing one is only picked up again when it marks HEAD. -$tag = $feedVersion -$tagged = git -C $root rev-parse --quiet --verify "refs/tags/$tag^{commit}" -if ($tagged -and $tagged -ne (git -C $root rev-parse HEAD)) { - throw "The tag $tag already marks commit $($tagged.Substring(0, 7)), not HEAD; choose a new version, or delete the tag if it was never pushed." -} -if ($tagged -and $currentVersion -ne $Version) { - throw "The tag $tag marks HEAD, but the project file holds version $currentVersion." -} - -# Every key is in hand before anything changes, so a missing one stops the run with nothing half done. -$keys = @{} -foreach ($target in $targets) { - $keys[$target] = if ($target -eq 'GitHub') { Get-GitHubKey } else { Get-NuGetKey } -} - -foreach ($target in $targets) { - if ((Get-PublishedVersions $target $packageId) -icontains $feedVersion) { - throw "$target already holds $packageId $feedVersion; choose a new version, or publish only to the feeds that lack it." - } -} - -# A run that stopped after its release commit or its tag resumes from them rather than making them again. -if ($currentVersion -ne $Version) { - $text = [IO.File]::ReadAllText($project) - $released = $text.Replace("$currentVersion", "$Version") - if ($released -eq $text) { - throw "The project file holds no $currentVersion element to change." - } - if ($PSCmdlet.ShouldProcess($project, "Commit release $Version")) { - Write-Host "Committing release $Version" - [IO.File]::WriteAllText($project, $released) - Invoke-Git commit --quiet -m "chore(locali): release $Version" -- $project - } -} - -if ($PSCmdlet.ShouldProcess("$packageId $Version", 'Pack in Release')) { - Write-Host "Packing $packageId $Version" - dotnet pack $project -c Release -o $artifacts -nologo -v:q - if ($LASTEXITCODE -ne 0) { - throw 'dotnet pack failed.' - } -} - -# The tag follows a successful pack, so a failed build leaves nothing marked as released. -if (-not $tagged -and $PSCmdlet.ShouldProcess($tag, 'Sign and tag the release commit')) { - Write-Host "Tagging $tag" - Invoke-Git tag --sign --message "$packageId $Version" $tag -} - -# Atomic, so the branch never receives the release commit without its tag, nor the tag without its commit. -if ($PSCmdlet.ShouldProcess("$upstream and $tag", 'Push the release commit and its tag')) { - Write-Host "Pushing the release commit and $tag to $upstream" - Invoke-Git push --quiet --atomic $remote "HEAD:$upstreamRef" "refs/tags/$tag" -} - -$package = Join-Path $artifacts "$packageId.$feedVersion.nupkg" - -foreach ($target in $targets) { - if ($PSCmdlet.ShouldProcess($feeds[$target], "Push $packageId $feedVersion")) { - Write-Host "Pushing to $target ($($feeds[$target]))" - dotnet nuget push $package --source $feeds[$target] --api-key $keys[$target] - if ($LASTEXITCODE -ne 0) { - throw "Pushing to $target failed." - } - } -} - -if (-not $WhatIfPreference) { - Write-Host "Published $packageId $Version to $($targets -join ' and ')." -} From a6138bff0af8afeeba71fc0cdbf0669fa1cbd501 Mon Sep 17 00:00:00 2001 From: Eyal Alon <3756641+soyeyalon@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:56:00 +0200 Subject: [PATCH 8/9] docs(locali): state what Versioning leaves to Locali --- README.md | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c9f54fc..a9328aa 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,13 @@ Two things go wrong quietly in a localized codebase, and a build catches neither ## Quick Start -The binary is `locali`, shipped as the dotnet tool `formaui-net.Locali`. A repository pins it in its tool -manifest and restores it, so every machine runs one version: +The binary is `locali`, shipped on nuget.org as the dotnet tool `formaui-net.Locali`. A repository +pins it in its tool manifest and restores it, so every machine runs one version; a preview +installs only when it's asked for: ```powershell dotnet new tool-manifest -dotnet tool install formaui-net.Locali +dotnet tool install formaui-net.Locali --prerelease dotnet locali --path ``` @@ -280,8 +281,10 @@ dotnet format Forma.Locali.slnx --verify-no-changes `tests/Forma.Locali.Engine.Tests` tests the engine, and `tests/Forma.Locali.Tests` tests the tool: its command line, its reporters, and the project loads only a run of the built tool can -show. `build.ps1` runs the build, both test projects and the format check, and `-Clean` removes every `bin`, -`obj` and `artifacts` folder first so they start from nothing. +show. `build.ps1` runs the build, both test projects and the format check, the fast gate, and `-Clean` removes +every `bin`, `obj` and `artifacts` folder first so they start from nothing. `build.ps1 -Full` runs the +full gate: the fast gate, then it packs the tool and verifies the package, as [Versioning](#versioning) +sets out. `SourceLinterTestFactory.Lint` compiles a class body against the real `IStringLocale` and returns what the callsite rules report, so a rule is tested against a bound compilation rather @@ -290,6 +293,33 @@ clean, so anything a rule reports there is a false positive. It does not cover e callsite can take — nothing there passes a values collection whole — so those belong in unit tests. +### Versioning + +Locali follows [FormaUI/Versioning `v1.0.0-preview.1`](https://github.com/FormaUI/Versioning/blob/v1.0.0-preview.1/README.md), which sets out how +it versions, branches, commits, releases and packages its work, and leaves Locali to state: + +- **Its public contract** is the command line: the [options](#options), the console output's format + and the exit codes under [Output](#output), the JSON report `--report` writes, and the rule ids in + the [rules](#rules) tables, each with what it reports and its severity. +- **Its version** is declared once, by `Version` in + [`src/Forma.Locali/Forma.Locali.csproj`](src/Forma.Locali/Forma.Locali.csproj), and the package, + the release's tag and the [changelog](CHANGELOG.md) repeat it. +- **Its fast gate** is `./build.ps1`, which `ci.yml` runs on every push but `main`'s and on every + pull request, with a lint of the sibling Forma's `dev`. **Its full gate** is `./build.ps1 -Full`, + which packs the tool, checks the package against its inventory, installs it from a local feed + outside the checkout and runs it over a sample project; `full.yml` runs it on every push to + `candidate`, with the fast gate on Windows and the lint of Forma, and keeps the verified package + as the artifact `packages`. +- **Its package** is `formaui-net.Locali` on nuget.org, a .NET tool for `net11.0` whose command is + `locali`, depending on no package. It holds the files + [`package-files.txt`](src/Forma.Locali/package-files.txt) lists, with its symbols beside it as a + `.snupkg`; the SDK builds a tool's symbol package from the `.pdb` files the tool holds, so those + stay in the package as well. +- **Its release** is published by `release.yml`, as [Releasing](#releasing) sets out. +- **Its changelog** is [`CHANGELOG.md`](CHANGELOG.md). + +A commit's message follows Versioning's [commits](https://github.com/FormaUI/Versioning/blob/v1.0.0-preview.1/docs/commits.md). + ### Releasing A release follows Versioning's From b18fb32be3f39bfa1c79df08686cce7f876c8e60 Mon Sep 17 00:00:00 2001 From: Eyal Alon <3756641+soyeyalon@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:59:17 +0200 Subject: [PATCH 9/9] chore(locali): release 1.0.0-preview.11 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b927c5..46460e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ The releases before `1.0.0-preview.11` are recorded by their [tags](https://gith ## `1.0.0-preview.11` -*Unreleased* +*November 5, 2026* - The package links each of its assemblies to the commit it was built from through Source Link, carries its symbols in a `.snupkg` beside it, and names this entry as its release notes.