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.