diff --git a/.github/workflows/dispatch-pythinker-home-sync.yml b/.github/workflows/dispatch-pythinker-home-sync.yml index 876b623b..a2ca32a9 100644 --- a/.github/workflows/dispatch-pythinker-home-sync.yml +++ b/.github/workflows/dispatch-pythinker-home-sync.yml @@ -22,28 +22,46 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + env: + DISPATCH_OWNER: TechMatrix-labs + DISPATCH_REPO: pythinker-home steps: + # Mint a short-lived installation token for the org-owned + # pythinker-release-bot App (Contents: write on pythinker-home only). + # Replaces a personal PAT: org-owned (survives member/org changes), + # ~1h TTL, minted fresh each run, scoped to the single private site repo. + - name: Mint GitHub App token for pythinker-home + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 + with: + app-id: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY }} + owner: ${{ env.DISPATCH_OWNER }} + repositories: ${{ env.DISPATCH_REPO }} + permission-contents: write + - name: Trigger pythinker-home sync env: - DISPATCH_TOKEN: ${{ secrets.PYTHINKER_HOME_REPO_DISPATCH_TOKEN }} + DISPATCH_TOKEN: ${{ steps.app-token.outputs.token }} SOURCE_REPO: ${{ github.repository }} RELEASE_TAG: ${{ github.sha }} + DISPATCH_OWNER: ${{ env.DISPATCH_OWNER }} + DISPATCH_REPO: ${{ env.DISPATCH_REPO }} run: | - if [ -z "$DISPATCH_TOKEN" ]; then - echo "::notice::Skipping pythinker-home sync: PYTHINKER_HOME_REPO_DISPATCH_TOKEN is not configured" - exit 0 + set -euo pipefail + if [ -z "${DISPATCH_TOKEN:-}" ]; then + echo "::error::No dispatch token: the pythinker-release-bot App token mint produced an empty value. Confirm PYTHINKER_RELEASE_BOT_APP_ID and PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY org secrets are set and the App is installed on ${DISPATCH_OWNER}/${DISPATCH_REPO} with Contents: Read and write." >&2 + exit 1 fi - payload=$(jq -n \ --arg source_repo "$SOURCE_REPO" \ --arg tag "$RELEASE_TAG" \ '{"event_type":"sync-pythinker-products","client_payload":{"source_repo":$source_repo,"tag":$tag}}') - curl --fail-with-body \ -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer $DISPATCH_TOKEN" \ - https://api.github.com/repos/TechMatrix-labs/pythinker-home/dispatches \ + "https://api.github.com/repos/${DISPATCH_OWNER}/${DISPATCH_REPO}/dispatches" \ -d "$payload" notify-failure: diff --git a/.github/workflows/release-pythinker-core.yml b/.github/workflows/release-pythinker-core.yml index 7e3c05eb..e28317ea 100644 --- a/.github/workflows/release-pythinker-core.yml +++ b/.github/workflows/release-pythinker-core.yml @@ -64,64 +64,3 @@ jobs: with: packages-dir: dist/pythinker-core - docs: - runs-on: ubuntu-latest - needs: validate - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python 3.14 - uses: actions/setup-python@v5 - with: - python-version: "3.14" - allow-prereleases: true - - - name: Set up uv - uses: astral-sh/setup-uv@v1 - with: - version: "0.8.5" - - - name: Install dependencies - run: uv sync --frozen --all-extras --project packages/pythinker-core - - - name: Generate API documentation - run: | - VERSION="${GITHUB_REF_NAME#pythinker-core-}" - uv run --project packages/pythinker-core pdoc pythinker_core \ - --docformat google \ - --footer-text "pythinker-core ${VERSION}" \ - -o packages/pythinker-core/docs - - - name: Disable Jekyll processing - run: touch packages/pythinker-core/docs/.nojekyll - - - name: Publish docs to gh-pages - env: - PYTHINKER_CORE_PAGES_TOKEN: ${{ secrets.PYTHINKER_CORE_PAGES_TOKEN }} - run: | - set -euo pipefail - if [[ -z "${PYTHINKER_CORE_PAGES_TOKEN}" ]]; then - echo "PYTHINKER_CORE_PAGES_TOKEN is not set; skipping API documentation publish." - exit 0 - fi - - VERSION="${GITHUB_REF_NAME#pythinker-core-}" - PAGES_REPO="https://x-access-token:${PYTHINKER_CORE_PAGES_TOKEN}@github.com/PythinkerAI/pythinker-core.git" - PAGES_DIR="${RUNNER_TEMP}/pythinker-core-gh-pages" - - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - rm -rf "$PAGES_DIR" - git clone --depth 1 --branch gh-pages "$PAGES_REPO" "$PAGES_DIR" - rsync -a --delete --exclude '.git' "packages/pythinker-core/docs/" "$PAGES_DIR/" - - git -C "$PAGES_DIR" add -A - if git -C "$PAGES_DIR" diff --cached --quiet; then - echo "No documentation changes to publish." - exit 0 - fi - - git -C "$PAGES_DIR" commit -m "docs: update for ${VERSION}" - git -C "$PAGES_DIR" push origin gh-pages diff --git a/CHANGELOG.md b/CHANGELOG.md index f9346141..ac0f1fc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- Release pipeline: migrate the pythinker-home website-sync dispatch to the org-owned `pythinker-release-bot` GitHub App and fail loud on an empty token; retire the dead pythinker-core API-docs gh-pages publish step; add exponential backoff to the native install scripts and fix the Windows installer's release-pagination cliff. - **Redesigned startup welcome banner.** The banner now uses a cleaner footer-chip layout: the "What's new / Update available" chip sits on the panel's bottom border, the headline/strapline/help lines align beside the robot logo, and the info grid drops its vertical separator. The robot art and palette are unchanged. - **Terminal-aware rendering for minimal and CI terminals.** The shell UI adapts to the terminal — ASCII glyph fallbacks for `TERM=dumb` and legacy Windows code pages, reduced-motion mode (`PYTHINKER_REDUCED_MOTION`), and `NO_COLOR`/`CLICOLOR` support that strips color cleanly — so output stays readable in CI logs, SSH panes, and bare terminals. - **Windows updates avoid encoded PowerShell.** Native updates now launch the signed Inno installer directly with Restart Manager flags instead of a `powershell.exe -EncodedCommand` helper, reducing antivirus command-line heuristic false positives. Windows bootstrap installs use visible `/SILENT` progress instead of fully suppressed setup, and the installer build signs bundled PE files plus Inno's setup/uninstaller/temp copies when signing credentials are configured. diff --git a/docs/public/install.ps1 b/docs/public/install.ps1 index 6e4d2e54..2f5be920 100644 --- a/docs/public/install.ps1 +++ b/docs/public/install.ps1 @@ -133,34 +133,64 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { Write-Logo +function Test-ReleaseHasInstaller($release) { + if ($release.draft -or $release.prerelease) { return $null } + $tag = [string]$release.tag_name + if (-not $tag) { return $null } + $candidate = $tag.TrimStart('v') + $exe = "PythinkerSetup-$candidate.exe" + $names = @($release.assets | ForEach-Object { [string]$_.name }) + if (($names -contains $exe) -and ($names -contains "$exe.sha256")) { return $candidate } + return $null +} + +function Format-ReleaseApiError($Uri, $ErrorRecord) { + $message = $ErrorRecord.Exception.Message + $status = $null + try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch { } + if ($status) { return "$Uri failed with HTTP ${status}: $message" } + return "$Uri failed: $message" +} + function Get-LatestVersion { Step "Looking up latest Pythinker release" - # The GitHub Release is published before every platform asset finishes - # uploading, and the /releases/latest endpoint is date-based, so it can - # briefly advertise a version whose Windows installer is still in flight. - # Resolve the newest published (non-draft, non-prerelease) release that - # actually carries PythinkerSetup-.exe AND its .sha256, so a release - # caught mid-publish never 404s the download below. - $api = "https://api.github.com/repos/$Repo/releases?per_page=20" - try { - $releases = Invoke-RestMethod -UseBasicParsing -Uri $api - } catch { - Fail "could not fetch releases from $api" - } - - foreach ($release in @($releases)) { - if ($release.draft -or $release.prerelease) { continue } - $tag = [string]$release.tag_name - if (-not $tag) { continue } - $candidate = $tag.TrimStart('v') - $exe = "PythinkerSetup-$candidate.exe" - $names = @($release.assets | ForEach-Object { [string]$_.name }) - if (($names -contains $exe) -and ($names -contains "$exe.sha256")) { - OK "Latest version is $candidate" - return $candidate + # /releases/latest is prerelease-excluding and not page-bound, so it is the + # correct primary source (fixes the per_page=20 pagination cliff). The + # GitHub Release can briefly advertise a version whose Windows installer is + # still uploading, so retry with exponential backoff (~6m). A paginated + # scan is only a fallback if /latest somehow lacks the asset pair. + $latestApi = "https://api.github.com/repos/$Repo/releases/latest" + $listApi = "https://api.github.com/repos/$Repo/releases?per_page=100" + $delay = 4 + $elapsed = 0 + $maxElapsed = 360 + $lastApiError = $null + while ($true) { + try { + $latest = Invoke-RestMethod -UseBasicParsing -Uri $latestApi + $found = Test-ReleaseHasInstaller $latest + if ($found) { OK "Latest version is $found"; return $found } + } catch { + $lastApiError = Format-ReleaseApiError $latestApi $_ + } + try { + $releases = Invoke-RestMethod -UseBasicParsing -Uri $listApi + foreach ($release in @($releases)) { + $found = Test-ReleaseHasInstaller $release + if ($found) { OK "Latest version is $found"; return $found } + } + } catch { + $lastApiError = Format-ReleaseApiError $listApi $_ + } + if ($elapsed -ge $maxElapsed) { + $detail = if ($lastApiError) { " Last API error: $lastApiError" } else { "" } + Fail "no published release has a ready Windows installer asset after ~${maxElapsed}s; try again shortly or pin `$env:PYTHINKER_VERSION.$detail" } + Step "Windows installer asset not ready yet; retry in ${delay}s" + Start-Sleep -Seconds $delay + $elapsed += $delay + $delay = [Math]::Min($delay * 2, 120) } - Fail "no published release has a ready Windows installer asset yet; try again shortly or pin `$env:PYTHINKER_VERSION" } function Read-ExpectedHash($Path) { diff --git a/docs/public/install.sh b/docs/public/install.sh index ba833b7c..b918f6ba 100755 --- a/docs/public/install.sh +++ b/docs/public/install.sh @@ -226,15 +226,24 @@ release_has_assets() { printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \ && printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\"" } +# Exponential backoff: the GitHub Release can briefly advertise a version +# whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total, +# before giving up — long enough to ride out a slow multi-arch upload. attempt=0 +delay=4 +elapsed=0 +max_elapsed=360 until release_has_assets; do attempt=$((attempt + 1)) - if [ "$attempt" -ge 6 ]; then - fail "release assets for v${VERSION} are not available yet: ${tarball_url} + if [ "$elapsed" -ge "$max_elapsed" ]; then + fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" fi - step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}/6)" - sleep 10 + step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}, retry in ${delay}s)" + sleep "$delay" + elapsed=$((elapsed + delay)) + delay=$((delay * 2)) + [ "$delay" -gt 120 ] && delay=120 done # --- download + verify -------------------------------------------------- diff --git a/scripts/install-native.sh b/scripts/install-native.sh index ba833b7c..b918f6ba 100755 --- a/scripts/install-native.sh +++ b/scripts/install-native.sh @@ -226,15 +226,24 @@ release_has_assets() { printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \ && printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\"" } +# Exponential backoff: the GitHub Release can briefly advertise a version +# whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total, +# before giving up — long enough to ride out a slow multi-arch upload. attempt=0 +delay=4 +elapsed=0 +max_elapsed=360 until release_has_assets; do attempt=$((attempt + 1)) - if [ "$attempt" -ge 6 ]; then - fail "release assets for v${VERSION} are not available yet: ${tarball_url} + if [ "$elapsed" -ge "$max_elapsed" ]; then + fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" fi - step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}/6)" - sleep 10 + step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}, retry in ${delay}s)" + sleep "$delay" + elapsed=$((elapsed + delay)) + delay=$((delay * 2)) + [ "$delay" -gt 120 ] && delay=120 done # --- download + verify -------------------------------------------------- diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 6e4d2e54..2f5be920 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -133,34 +133,64 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { Write-Logo +function Test-ReleaseHasInstaller($release) { + if ($release.draft -or $release.prerelease) { return $null } + $tag = [string]$release.tag_name + if (-not $tag) { return $null } + $candidate = $tag.TrimStart('v') + $exe = "PythinkerSetup-$candidate.exe" + $names = @($release.assets | ForEach-Object { [string]$_.name }) + if (($names -contains $exe) -and ($names -contains "$exe.sha256")) { return $candidate } + return $null +} + +function Format-ReleaseApiError($Uri, $ErrorRecord) { + $message = $ErrorRecord.Exception.Message + $status = $null + try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch { } + if ($status) { return "$Uri failed with HTTP ${status}: $message" } + return "$Uri failed: $message" +} + function Get-LatestVersion { Step "Looking up latest Pythinker release" - # The GitHub Release is published before every platform asset finishes - # uploading, and the /releases/latest endpoint is date-based, so it can - # briefly advertise a version whose Windows installer is still in flight. - # Resolve the newest published (non-draft, non-prerelease) release that - # actually carries PythinkerSetup-.exe AND its .sha256, so a release - # caught mid-publish never 404s the download below. - $api = "https://api.github.com/repos/$Repo/releases?per_page=20" - try { - $releases = Invoke-RestMethod -UseBasicParsing -Uri $api - } catch { - Fail "could not fetch releases from $api" - } - - foreach ($release in @($releases)) { - if ($release.draft -or $release.prerelease) { continue } - $tag = [string]$release.tag_name - if (-not $tag) { continue } - $candidate = $tag.TrimStart('v') - $exe = "PythinkerSetup-$candidate.exe" - $names = @($release.assets | ForEach-Object { [string]$_.name }) - if (($names -contains $exe) -and ($names -contains "$exe.sha256")) { - OK "Latest version is $candidate" - return $candidate + # /releases/latest is prerelease-excluding and not page-bound, so it is the + # correct primary source (fixes the per_page=20 pagination cliff). The + # GitHub Release can briefly advertise a version whose Windows installer is + # still uploading, so retry with exponential backoff (~6m). A paginated + # scan is only a fallback if /latest somehow lacks the asset pair. + $latestApi = "https://api.github.com/repos/$Repo/releases/latest" + $listApi = "https://api.github.com/repos/$Repo/releases?per_page=100" + $delay = 4 + $elapsed = 0 + $maxElapsed = 360 + $lastApiError = $null + while ($true) { + try { + $latest = Invoke-RestMethod -UseBasicParsing -Uri $latestApi + $found = Test-ReleaseHasInstaller $latest + if ($found) { OK "Latest version is $found"; return $found } + } catch { + $lastApiError = Format-ReleaseApiError $latestApi $_ + } + try { + $releases = Invoke-RestMethod -UseBasicParsing -Uri $listApi + foreach ($release in @($releases)) { + $found = Test-ReleaseHasInstaller $release + if ($found) { OK "Latest version is $found"; return $found } + } + } catch { + $lastApiError = Format-ReleaseApiError $listApi $_ + } + if ($elapsed -ge $maxElapsed) { + $detail = if ($lastApiError) { " Last API error: $lastApiError" } else { "" } + Fail "no published release has a ready Windows installer asset after ~${maxElapsed}s; try again shortly or pin `$env:PYTHINKER_VERSION.$detail" } + Step "Windows installer asset not ready yet; retry in ${delay}s" + Start-Sleep -Seconds $delay + $elapsed += $delay + $delay = [Math]::Min($delay * 2, 120) } - Fail "no published release has a ready Windows installer asset yet; try again shortly or pin `$env:PYTHINKER_VERSION" } function Read-ExpectedHash($Path) { diff --git a/tests/test_release_update_pipeline.py b/tests/test_release_update_pipeline.py index f25e61df..3a49d377 100644 --- a/tests/test_release_update_pipeline.py +++ b/tests/test_release_update_pipeline.py @@ -50,7 +50,10 @@ def test_install_scripts_gate_on_asset_readiness() -> None: waits for this version's archive + checksum before downloading. """ ps1 = (ROOT / "scripts" / "install.ps1").read_text() - assert "releases?per_page=" in ps1, "install.ps1 must scan releases, not trust /releases/latest" + assert "releases/latest" in ps1 + assert "releases?per_page=100" in ps1 + assert "Test-ReleaseHasInstaller" in ps1 + assert "Format-ReleaseApiError" in ps1 assert "$release.prerelease" in ps1 assert '"$exe.sha256"' in ps1 @@ -65,6 +68,18 @@ def test_install_scripts_gate_on_asset_readiness() -> None: assert (ROOT / "web" / "public" / "install.sh").read_text() == sh +def test_site_dispatch_uses_scoped_github_app_token_and_fails_loud() -> None: + workflow = (WORKFLOWS / "dispatch-pythinker-home-sync.yml").read_text() + + assert "PYTHINKER_HOME_REPO_DISPATCH_TOKEN" not in workflow + assert "actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349" in workflow + assert "permission-contents: write" in workflow + assert "PYTHINKER_RELEASE_BOT_APP_ID" in workflow + assert "PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY" in workflow + assert "exit 1" in workflow + assert "Skipping pythinker-home sync" not in workflow + + def test_windows_installer_signs_update_artifacts_when_credentials_are_available() -> None: installer_script = (ROOT / "packages" / "windows-installer" / "installer.iss").read_text() build_script = (ROOT / "packages" / "windows-installer" / "build.ps1").read_text() diff --git a/web/public/install.ps1 b/web/public/install.ps1 index 6e4d2e54..2f5be920 100644 --- a/web/public/install.ps1 +++ b/web/public/install.ps1 @@ -133,34 +133,64 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { Write-Logo +function Test-ReleaseHasInstaller($release) { + if ($release.draft -or $release.prerelease) { return $null } + $tag = [string]$release.tag_name + if (-not $tag) { return $null } + $candidate = $tag.TrimStart('v') + $exe = "PythinkerSetup-$candidate.exe" + $names = @($release.assets | ForEach-Object { [string]$_.name }) + if (($names -contains $exe) -and ($names -contains "$exe.sha256")) { return $candidate } + return $null +} + +function Format-ReleaseApiError($Uri, $ErrorRecord) { + $message = $ErrorRecord.Exception.Message + $status = $null + try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch { } + if ($status) { return "$Uri failed with HTTP ${status}: $message" } + return "$Uri failed: $message" +} + function Get-LatestVersion { Step "Looking up latest Pythinker release" - # The GitHub Release is published before every platform asset finishes - # uploading, and the /releases/latest endpoint is date-based, so it can - # briefly advertise a version whose Windows installer is still in flight. - # Resolve the newest published (non-draft, non-prerelease) release that - # actually carries PythinkerSetup-.exe AND its .sha256, so a release - # caught mid-publish never 404s the download below. - $api = "https://api.github.com/repos/$Repo/releases?per_page=20" - try { - $releases = Invoke-RestMethod -UseBasicParsing -Uri $api - } catch { - Fail "could not fetch releases from $api" - } - - foreach ($release in @($releases)) { - if ($release.draft -or $release.prerelease) { continue } - $tag = [string]$release.tag_name - if (-not $tag) { continue } - $candidate = $tag.TrimStart('v') - $exe = "PythinkerSetup-$candidate.exe" - $names = @($release.assets | ForEach-Object { [string]$_.name }) - if (($names -contains $exe) -and ($names -contains "$exe.sha256")) { - OK "Latest version is $candidate" - return $candidate + # /releases/latest is prerelease-excluding and not page-bound, so it is the + # correct primary source (fixes the per_page=20 pagination cliff). The + # GitHub Release can briefly advertise a version whose Windows installer is + # still uploading, so retry with exponential backoff (~6m). A paginated + # scan is only a fallback if /latest somehow lacks the asset pair. + $latestApi = "https://api.github.com/repos/$Repo/releases/latest" + $listApi = "https://api.github.com/repos/$Repo/releases?per_page=100" + $delay = 4 + $elapsed = 0 + $maxElapsed = 360 + $lastApiError = $null + while ($true) { + try { + $latest = Invoke-RestMethod -UseBasicParsing -Uri $latestApi + $found = Test-ReleaseHasInstaller $latest + if ($found) { OK "Latest version is $found"; return $found } + } catch { + $lastApiError = Format-ReleaseApiError $latestApi $_ + } + try { + $releases = Invoke-RestMethod -UseBasicParsing -Uri $listApi + foreach ($release in @($releases)) { + $found = Test-ReleaseHasInstaller $release + if ($found) { OK "Latest version is $found"; return $found } + } + } catch { + $lastApiError = Format-ReleaseApiError $listApi $_ + } + if ($elapsed -ge $maxElapsed) { + $detail = if ($lastApiError) { " Last API error: $lastApiError" } else { "" } + Fail "no published release has a ready Windows installer asset after ~${maxElapsed}s; try again shortly or pin `$env:PYTHINKER_VERSION.$detail" } + Step "Windows installer asset not ready yet; retry in ${delay}s" + Start-Sleep -Seconds $delay + $elapsed += $delay + $delay = [Math]::Min($delay * 2, 120) } - Fail "no published release has a ready Windows installer asset yet; try again shortly or pin `$env:PYTHINKER_VERSION" } function Read-ExpectedHash($Path) { diff --git a/web/public/install.sh b/web/public/install.sh index ba833b7c..b918f6ba 100755 --- a/web/public/install.sh +++ b/web/public/install.sh @@ -226,15 +226,24 @@ release_has_assets() { printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \ && printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\"" } +# Exponential backoff: the GitHub Release can briefly advertise a version +# whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total, +# before giving up — long enough to ride out a slow multi-arch upload. attempt=0 +delay=4 +elapsed=0 +max_elapsed=360 until release_has_assets; do attempt=$((attempt + 1)) - if [ "$attempt" -ge 6 ]; then - fail "release assets for v${VERSION} are not available yet: ${tarball_url} + if [ "$elapsed" -ge "$max_elapsed" ]; then + fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" fi - step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}/6)" - sleep 10 + step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}, retry in ${delay}s)" + sleep "$delay" + elapsed=$((elapsed + delay)) + delay=$((delay * 2)) + [ "$delay" -gt 120 ] && delay=120 done # --- download + verify --------------------------------------------------