diff --git a/install b/install index 2cf359ba0..8bbf4a6e7 100755 --- a/install +++ b/install @@ -205,11 +205,26 @@ else if [ -z "$requested_version" ]; then url="https://github.com/AltimateAI/altimate-code/releases/latest/download/$filename" - specific_version=$(curl -s https://api.github.com/repos/AltimateAI/altimate-code/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') - - if [[ $? -ne 0 || -z "$specific_version" ]]; then - echo -e "${RED}Failed to fetch version information${NC}" - exit 1 + # The download above resolves "latest" server-side, so this API call only + # feeds the version display and the already-installed short-circuit. A + # transient api.github.com blip or the unauthenticated rate limit + # (60/hr/IP) must NOT abort the install — retry a few times with --fail + # (so a 504 retries instead of parsing an error body), then proceed + # without the version string. + # + # --max-time 10 bounds a dead-air socket (curl's default has no transfer + # cap), and the trailing `|| true` is load-bearing: under `set -euo + # pipefail`, a failing `curl --fail` propagates through the pipeline and + # the assignment, so `set -e` would abort the script before the loop can + # retry or degrade. `|| true` lets the failure resolve to an empty string. + specific_version="" + for attempt in 1 2 3; do + specific_version=$(curl -fsSL --max-time 10 https://api.github.com/repos/AltimateAI/altimate-code/releases/latest 2>/dev/null | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' || true) + [ -n "$specific_version" ] && break + [ "$attempt" -lt 3 ] && sleep "$attempt" + done + if [ -z "$specific_version" ]; then + echo -e "${MUTED}Could not resolve the latest version from GitHub (API unavailable) — installing the latest release anyway.${NC}" fi else # Strip leading 'v' if present @@ -255,11 +270,14 @@ check_version() { if [ -n "$probe" ]; then installed_version=$("$probe" --version 2>/dev/null || echo "") - if [[ "$installed_version" != "$specific_version" ]]; then - print_message info "${MUTED}Installed version: ${NC}$installed_version." - else + # Only short-circuit on a real version match. When the latest version + # couldn't be resolved (API unavailable → specific_version empty), never + # treat an empty==empty as "already installed" — fall through and reinstall. + if [ -n "$specific_version" ] && [[ "$installed_version" == "$specific_version" ]]; then print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed${NC}" exit 0 + elif [ -n "$installed_version" ]; then + print_message info "${MUTED}Installed version: ${NC}$installed_version." fi fi } @@ -357,7 +375,7 @@ download_with_progress() { } download_and_install() { - print_message info "\n${MUTED}Installing ${NC}altimate ${MUTED}version: ${NC}$specific_version" + print_message info "\n${MUTED}Installing ${NC}altimate ${MUTED}version: ${NC}${specific_version:-latest}" local tmp_dir="${TMPDIR:-/tmp}/altimate_install_$$" mkdir -p "$tmp_dir" diff --git a/install.ps1 b/install.ps1 index bcab7223f..640300df2 100644 --- a/install.ps1 +++ b/install.ps1 @@ -3,7 +3,7 @@ # # Mirrors ./install (the bash installer for macOS/Linux): it downloads the # Bun-compiled standalone executable (altimate.exe) from GitHub releases and -# drops it in %USERPROFILE%\.altimate\bin — it does NOT depend on npm/Node. +# drops it in %USERPROFILE%\.altimate\bin - it does NOT depend on npm/Node. # # Usage: # powershell -c "irm https://www.altimate.sh/install.ps1 | iex" @@ -64,8 +64,8 @@ if ($Help) { exit 0 } -# A single P/Invoke type carries both native calls we need — the AVX2 CPU probe -# (kernel32) and the PATH-change broadcast (user32) — so we Add-Type once instead +# A single P/Invoke type carries both native calls we need - the AVX2 CPU probe +# (kernel32) and the PATH-change broadcast (user32) - so we Add-Type once instead # of compiling a throwaway type per call site. function Initialize-Native { if (-not ("Win32.AltimateNative" -as [type])) { @@ -101,27 +101,43 @@ function Test-Avx2 { Initialize-Native return [bool][Win32.AltimateNative]::IsProcessorFeaturePresent(40) } catch { - # If detection fails, assume no AVX2 and fall back to the baseline build — + # If detection fails, assume no AVX2 and fall back to the baseline build - # the baseline binary runs everywhere, an AVX2 binary on a non-AVX2 CPU crashes. return $false } } # --------------------------------------------------------------------------- -# Resolve version (once) — latest tag or a pinned release +# Resolve version (once) - latest tag or a pinned release # --------------------------------------------------------------------------- if ([string]::IsNullOrWhiteSpace($Version)) { $useLatest = $true - try { - $rel = Invoke-RestMethod -Uri "https://api.github.com/repos/AltimateAI/altimate-code/releases/latest" -Headers @{ "User-Agent" = "altimate-install" } - $specificVersion = ($rel.tag_name -replace '^v', '') - } catch { - Write-Err "Failed to fetch version information" - exit 1 + # The download below resolves "latest" server-side (releases/latest/download), + # so this API call only feeds the version-string display and the + # already-installed short-circuit. A transient api.github.com blip or the + # unauthenticated rate limit (60/hr/IP) must NOT abort the install - retry a + # few times, then proceed without the version string. + $specificVersion = "" + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + # -TimeoutSec 10 bounds a stuck socket: Invoke-RestMethod defaults to 100s + # on PS 5.1 and is effectively unbounded on PS 7+, so without it three + # back-to-back retries on dead air could freeze for minutes. + $rel = Invoke-RestMethod -Uri "https://api.github.com/repos/AltimateAI/altimate-code/releases/latest" -Headers @{ "User-Agent" = "altimate-install" } -TimeoutSec 10 + $specificVersion = ($rel.tag_name -replace '^v', '') + if (-not [string]::IsNullOrWhiteSpace($specificVersion)) { break } + } catch {} + if ($attempt -lt 3) { Start-Sleep -Seconds $attempt } } if ([string]::IsNullOrWhiteSpace($specificVersion)) { - Write-Err "Failed to fetch version information" - exit 1 + Write-Muted "Could not resolve the latest version from GitHub (API unavailable) - installing the latest release anyway." + # Reset to $null (not ""): the already-installed short-circuit below compares + # $installedVersion -eq $specificVersion. If the version probe of a missing or + # corrupt binary also yields "", an "" -eq "" match would falsely report + # "already installed" and skip the reinstall. $null -eq "" is $false, so the + # comparison correctly falls through; the banner still shows "latest" because + # if ($specificVersion) treats $null as falsy. + $specificVersion = $null } } else { $useLatest = $false @@ -177,19 +193,13 @@ function Install-Target { } Write-Host "" - Write-Host "Installing $App version: $specificVersion" + Write-Host "Installing $App version: $(if ($specificVersion) { $specificVersion } else { 'latest' })" $tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "altimate_install_$PID" New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null $zipPath = Join-Path $tmpDir $filename try { - # NOTE: integrity verification (SHA256/signature) of the archive is - # intentionally deferred to match the bash installer's posture — both rely - # on HTTPS from github.com release assets. Releases do not currently publish - # a checksums file; adding one + verifying it in both installers is tracked - # as a follow-up. See PR #930 discussion. - # # Prefer curl.exe (ships with Windows 10 1803+) for a fast download with # --fail so HTTP errors don't write an error page to disk; fall back to # Invoke-WebRequest where curl.exe is unavailable. @@ -210,7 +220,7 @@ function Install-Target { # Windows locks a running .exe, so `altimate upgrade` (which re-runs this # installer) can't overwrite the binary that is currently executing. Windows - # *does* allow renaming a running exe — move the old one aside first, then + # *does* allow renaming a running exe - move the old one aside first, then # drop the new one in. Best-effort cleanup of the stale copy afterward. if (Test-Path $InstalledBinary) { $stale = "$InstalledBinary.old" @@ -237,7 +247,7 @@ if (-not $needsBaseline) { & $InstalledBinary --version *> $null $code = $LASTEXITCODE if ($code -eq 3221225501 -or $code -eq 1073741795 -or $code -eq -1073741795) { - Write-Muted "CPU lacks AVX2 — reinstalling the baseline build" + Write-Muted "CPU lacks AVX2 - reinstalling the baseline build" Install-Target -Baseline:$true } } diff --git a/packages/opencode/test/install/version-fetch-resilience.test.ts b/packages/opencode/test/install/version-fetch-resilience.test.ts new file mode 100644 index 000000000..6f492fc2b --- /dev/null +++ b/packages/opencode/test/install/version-fetch-resilience.test.ts @@ -0,0 +1,67 @@ +/** + * Latest-version resolution must be resilient, in BOTH installers. + * + * The `latest` install path hits api.github.com/.../releases/latest only for the + * version-string display + the already-installed short-circuit — the download + * itself uses releases/latest/download/ (server-side latest). A transient + * 504 or the 60/hr/IP unauthenticated rate limit must NOT abort the install: + * retry a few times, then degrade gracefully and install latest anyway. + */ +import { describe, test, expect } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +const REPO_ROOT = join(import.meta.dir, "../../../..") +const BASH = readFileSync(join(REPO_ROOT, "install"), "utf-8") +const PS1 = readFileSync(join(REPO_ROOT, "install.ps1"), "utf-8") + +describe("bash installer — latest-version fetch is non-fatal", () => { + test("retries the releases/latest API call", () => { + expect(BASH).toContain("for attempt in 1 2 3") + // --fail so a 504 errors out (and retries) instead of parsing an error body. + expect(BASH).toContain("curl -fsSL --max-time 10 https://api.github.com") + }) + + test("the retry assignment absorbs curl failure so set -e can't abort it", () => { + // Under `set -euo pipefail`, a failing `curl --fail` propagates through the + // pipeline + assignment and aborts the script before the loop can retry or + // degrade. The trailing `|| true` keeps the retry loop alive. + expect(BASH).toMatch(/curl -fsSL --max-time 10 https:\/\/api\.github\.com[^\n]*\|\| true/) + }) + + test("bounds the API call with a transfer timeout", () => { + expect(BASH).toContain("--max-time 10") + }) + + test("degrades gracefully instead of exiting on API failure", () => { + expect(BASH).toContain("installing the latest release anyway") + // The old fatal hard-fail must be gone from the latest path. + expect(BASH).not.toContain("Failed to fetch version information") + }) + + test("only short-circuits as already-installed on a real version match", () => { + expect(BASH).toContain('[ -n "$specific_version" ] && [[ "$installed_version" == "$specific_version" ]]') + }) +}) + +describe("PowerShell installer — latest-version fetch is non-fatal", () => { + test("retries the releases/latest API call", () => { + expect(PS1).toContain("for ($attempt = 1; $attempt -le 3; $attempt++)") + }) + + test("bounds the API call with a request timeout", () => { + expect(PS1).toContain("-TimeoutSec 10") + }) + + test("degrades gracefully instead of exiting on API failure", () => { + expect(PS1).toContain("installing the latest release anyway") + // The old fatal hard-fail must be gone. + expect(PS1).not.toContain("Failed to fetch version information") + }) + + test("resets the unresolved version to $null so empty==empty can't false-match", () => { + // $installedVersion -eq $specificVersion with both "" would falsely report + // "already installed" for a missing/corrupt binary; $null -eq "" is $false. + expect(PS1).toContain("$specificVersion = $null") + }) +}) diff --git a/packages/opencode/test/release-validation/windows-installer-930-codex.test.ts b/packages/opencode/test/release-validation/windows-installer-930-codex.test.ts index 92e609129..879383881 100644 --- a/packages/opencode/test/release-validation/windows-installer-930-codex.test.ts +++ b/packages/opencode/test/release-validation/windows-installer-930-codex.test.ts @@ -51,13 +51,17 @@ describe("PR #930 install.ps1 release URL construction", () => { expect(versionBlock).toContain("exit 1") }) - test("latest-version resolution requires a nonblank GitHub release tag", () => { + test("latest-version resolution retries then degrades instead of hard-failing", () => { const versionBlock = scriptBlock("# Resolve version (once)", "# Skip if the requested version") expect(versionBlock).toContain("[string]::IsNullOrWhiteSpace($Version)") expect(versionBlock).toContain('"User-Agent" = "altimate-install"') expect(versionBlock).toContain("$specificVersion = ($rel.tag_name -replace '^v', '')") expect(versionBlock).toContain("[string]::IsNullOrWhiteSpace($specificVersion)") - expect(versionBlock.match(/Failed to fetch version information/g)?.length).toBeGreaterThanOrEqual(2) + // A transient releases/latest API blip must not abort the install: retry a few + // times, then degrade gracefully (the download resolves "latest" server-side). + expect(versionBlock).toContain("for ($attempt = 1; $attempt -le 3; $attempt++)") + expect(versionBlock).toContain("installing the latest release anyway") + expect(versionBlock).not.toContain("Failed to fetch version information") }) }) @@ -102,7 +106,10 @@ describe("PR #930 install.ps1 error handling and idempotency", () => { expect(INSTALL_PS1).toContain('$ErrorActionPreference = "Stop"') expect(INSTALL_PS1.match(/\btry\s*\{/g)?.length).toBeGreaterThanOrEqual(4) expect(INSTALL_PS1.match(/\bcatch\s*\{/g)?.length).toBeGreaterThanOrEqual(4) - expect(INSTALL_PS1.match(/exit 1/g)?.length).toBeGreaterThanOrEqual(3) + // The unsupported-arch and pinned-version-not-found paths still hard-fail + // (exit 1). The latest-version path no longer does: it degrades gracefully on + // a transient API blip rather than aborting the install. + expect(INSTALL_PS1.match(/exit 1/g)?.length).toBeGreaterThanOrEqual(2) }) test("skips reinstall when altimate or altimate-code already reports the target version", () => {