From 1a9490f0379365e28728d3205ad290cf8a24bd87 Mon Sep 17 00:00:00 2001 From: cangelosilima Date: Sun, 4 Oct 2026 14:37:53 -0300 Subject: [PATCH] Retry public NuGet tool installation during feed propagation --- .github/scripts/install-nuget-tool.ps1 | 27 ++++++++++++ .github/scripts/install-nuget-tool.test.ps1 | 49 +++++++++++++++++++++ .github/workflows/cli-publish-nuget.yml | 30 +++---------- docs/nuget-publishing.md | 6 +++ 4 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 .github/scripts/install-nuget-tool.ps1 create mode 100644 .github/scripts/install-nuget-tool.test.ps1 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