diff --git a/.github/scripts/install-nuget-tool.ps1 b/.github/scripts/install-nuget-tool.ps1
new file mode 100644
index 0000000..31a58f0
--- /dev/null
+++ b/.github/scripts/install-nuget-tool.ps1
@@ -0,0 +1,27 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)][string] $Version,
+ [string] $ToolPath,
+ [ValidateRange(1, 60)][int] $Attempts = 20,
+ [ValidateRange(0, 60)][int] $RetrySeconds = 30
+)
+$ErrorActionPreference = 'Stop'
+$PSNativeCommandUseErrorActionPreference = $false
+$config = [IO.Path]::GetTempFileName()
+try {
+ '' | Set-Content $config
+ $installArgs = @('tool', 'install', 'SyncSql.Cli', '--version', $Version, '--configfile', $config, '--no-cache')
+ if ($ToolPath) { $installArgs += @('--tool-path', $ToolPath) } else { $installArgs += '--global' }
+ for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
+ Write-Host "Installing SyncSql.Cli $Version from nuget.org (attempt $attempt/$Attempts)."
+ & dotnet @installArgs
+ if ($LASTEXITCODE -eq 0) { return }
+ if ($attempt -lt $Attempts) {
+ Write-Host "Installation has not succeeded; retrying in $RetrySeconds seconds."
+ Start-Sleep -Seconds $RetrySeconds
+ }
+ }
+ throw "Installation of SyncSql.Cli $Version from nuget.org failed after $Attempts attempts. Review the dotnet errors above."
+} finally {
+ Remove-Item -LiteralPath $config
+}
diff --git a/.github/scripts/install-nuget-tool.test.ps1 b/.github/scripts/install-nuget-tool.test.ps1
new file mode 100644
index 0000000..af44889
--- /dev/null
+++ b/.github/scripts/install-nuget-tool.test.ps1
@@ -0,0 +1,49 @@
+$global:nugetInstallTestState = @{ exitCodes = @(); calls = @() }
+$ErrorActionPreference = 'Stop'
+function dotnet {
+ $configPath = $args[[Array]::IndexOf($args, '--configfile') + 1]
+ [xml] $config = Get-Content $configPath
+ Assert-True ($config.configuration.packageSources.add.Count -eq 1) 'Only one public source should be configured.'
+ Assert-True ($config.configuration.packageSources.add.value -eq 'https://api.nuget.org/v3/index.json') 'Installation must use nuget.org.'
+ Assert-True ($null -ne $config.configuration.packageSources.clear) 'Inherited sources must be cleared.'
+ $global:nugetInstallTestState.calls += ,@($args)
+ $global:LASTEXITCODE = $global:nugetInstallTestState.exitCodes[$global:nugetInstallTestState.calls.Count - 1]
+}
+function Assert-True([bool] $Condition, [string] $Message) {
+ if (-not $Condition) { throw $Message }
+}
+
+# Download readiness must not be treated as install readiness: retry the CLI itself.
+$global:nugetInstallTestState.exitCodes = @(1, 1, 0)
+& "$PSScriptRoot/install-nuget-tool.ps1" -Version 2026.10.4.8 -Attempts 3 -RetrySeconds 0
+Assert-True ($global:nugetInstallTestState.calls.Count -eq 3) 'The failed install was not retried.'
+foreach ($call in $global:nugetInstallTestState.calls) {
+ Assert-True ($call -contains '--global') 'Workflow installation must use global scope.'
+ Assert-True ($call -contains '2026.10.4.8') 'Every attempt must pin the exact version.'
+ Assert-True ($call -contains '--no-cache') 'Every attempt must bypass the HTTP cache.'
+ $configPath = $call[[Array]::IndexOf($call, '--configfile') + 1]
+ Assert-True (-not (Test-Path $configPath)) 'The source config was not cleaned up.'
+}
+
+$global:nugetInstallTestState.calls = @()
+$global:nugetInstallTestState.exitCodes = @(1, 1)
+$failed = $false
+try {
+ & "$PSScriptRoot/install-nuget-tool.ps1" -Version 2026.10.4.8 -Attempts 2 -RetrySeconds 0
+} catch {
+ Assert-True ($_.Exception.Message -like '*failed after 2 attempts*') 'Unexpected installation failure.'
+ $failed = $true
+}
+Assert-True $failed 'Exhausted retries must fail the job.'
+Assert-True ($global:nugetInstallTestState.calls.Count -eq 2) 'Retry count exceeded its limit.'
+
+$global:nugetInstallTestState.calls = @()
+$global:nugetInstallTestState.exitCodes = @(0)
+& "$PSScriptRoot/install-nuget-tool.ps1" -Version 2026.10.4.8 -Attempts 3 -RetrySeconds 0 -ToolPath fixture-tools
+Assert-True ($global:nugetInstallTestState.calls.Count -eq 1) 'Successful installation should not be retried.'
+Assert-True ($global:nugetInstallTestState.calls[0] -contains '--tool-path') 'Isolated verification must use the requested tool path.'
+Assert-True ($global:nugetInstallTestState.calls[0] -notcontains '--global') 'Isolated verification must not install globally.'
+Write-Host 'NuGet install retry tests passed.'
+
+
+Remove-Variable nugetInstallTestState -Scope Global
diff --git a/.github/workflows/cli-publish-nuget.yml b/.github/workflows/cli-publish-nuget.yml
index 265a81b..d2dafb2 100644
--- a/.github/workflows/cli-publish-nuget.yml
+++ b/.github/workflows/cli-publish-nuget.yml
@@ -13,6 +13,7 @@ on:
- '.github/workflows/cli-publish-windows.yml'
- '.github/scripts/*-cli-install.ps1'
- '.github/scripts/prepare-winget.ps1'
+ - '.github/scripts/install-nuget-tool*.ps1'
pull_request:
paths:
- 'cli/**'
@@ -24,6 +25,7 @@ on:
- '.github/workflows/cli-publish-windows.yml'
- '.github/scripts/*-cli-install.ps1'
- '.github/scripts/prepare-winget.ps1'
+ - '.github/scripts/install-nuget-tool*.ps1'
workflow_dispatch:
permissions:
@@ -47,6 +49,9 @@ jobs:
global-json-file: global.json
- name: Test release automation
run: node --test .github/scripts/nuget-release.test.cjs
+ - name: Test public-install retry behavior
+ shell: pwsh
+ run: ./.github/scripts/install-nuget-tool.test.ps1
- name: Resolve CalVer
id: version
shell: pwsh
@@ -154,33 +159,12 @@ jobs:
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- - name: Wait for the published NuGet version
- shell: pwsh
- env:
- VERSION: ${{ needs.package.outputs.version }}
- run: |
- $url = "https://api.nuget.org/v3-flatcontainer/syncsql.cli/$env:VERSION/syncsql.cli.$env:VERSION.nupkg"
- $visible = $false
- for ($attempt = 1; $attempt -le 20; $attempt++) {
- try {
- Invoke-WebRequest $url -Method Head -TimeoutSec 30 | Out-Null
- $visible = $true
- break
- } catch {
- Write-Host "NuGet version not available yet (attempt $attempt/20)."
- if ($attempt -lt 20) { Start-Sleep -Seconds 30 }
- }
- }
- if (-not $visible) { throw 'Published package did not become available on nuget.org.' }
- - name: Install from nuget.org
+ - name: Install the exact public version, retrying until the feed is ready
shell: pwsh
env:
VERSION: ${{ needs.package.outputs.version }}
run: |
- $config = Join-Path $env:RUNNER_TEMP 'install-nuget.config'
- '' | Set-Content $config
- dotnet tool install --global SyncSql.Cli --version $env:VERSION --configfile $config --no-cache
- if ($LASTEXITCODE) { throw 'Published dotnet tool installation failed.' }
+ ./.github/scripts/install-nuget-tool.ps1 -Version $env:VERSION
Join-Path ([Environment]::GetFolderPath('UserProfile')) '.dotnet/tools' >> $env:GITHUB_PATH
- name: Verify installed command and version
shell: pwsh
diff --git a/docs/nuget-publishing.md b/docs/nuget-publishing.md
index f5af323..5fa0086 100644
--- a/docs/nuget-publishing.md
+++ b/docs/nuget-publishing.md
@@ -81,6 +81,12 @@ its NuGet publish job. Chocolatey and WinGet use the CLI version, release tag
and tested commit. See [Windows package releases](windows-packaging.md) for
registry credentials, validation and installation commands.
+After CLI publication, fresh Linux, Windows and macOS runners install the exact
+version directly from nuget.org. Each runner retries `dotnet tool install` up to
+20 times with 30 seconds between attempts, then verifies the command, version,
+help output and uninstall. Package download availability can precede feed lookup
+availability, so the actual installation determines readiness.
+
## Consume
```bash