Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/scripts/install-nuget-tool.ps1
Original file line number Diff line number Diff line change
@@ -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 {
'<configuration><packageSources><clear/><add key="nuget.org" value="https://api.nuget.org/v3/index.json"/></packageSources></configuration>' | 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
}
49 changes: 49 additions & 0 deletions .github/scripts/install-nuget-tool.test.ps1
Original file line number Diff line number Diff line change
@@ -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
30 changes: 7 additions & 23 deletions .github/workflows/cli-publish-nuget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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'
'<configuration><packageSources><clear/><add key="nuget.org" value="https://api.nuget.org/v3/index.json"/></packageSources></configuration>' | 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
Expand Down
6 changes: 6 additions & 0 deletions docs/nuget-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading