diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea67f3f..bec630b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,11 @@ jobs: shell: bash run: sh scripts/test-installer-messages.sh + - name: Unit test installer fallback logic + if: runner.os == 'Linux' + shell: bash + run: sh scripts/test-installer-fallback.sh + - name: Check PowerShell installer syntax if: runner.os == 'Windows' shell: pwsh @@ -148,48 +153,30 @@ jobs: (cd "$download_dir" && shasum -a 256 "$archive" > checksums.txt) fi + # Fixed-port fixture server (matches the S3 fallback steps below). + # Avoids the ephemeral-port + port-file write race that intermittently + # hung before writing its port on macOS runners. server_log="$fixture_root/http-server.log" - server_port_file="$fixture_root/http-server.port" - FIXTURE_ROOT="$fixture_root" SERVER_PORT_FILE="$server_port_file" python3 - <<'PY' >"$server_log" 2>&1 & - import functools - import http.server - import os - import pathlib - - root = os.environ["FIXTURE_ROOT"] - port_file = pathlib.Path(os.environ["SERVER_PORT_FILE"]) - handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=root) - - with http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) as httpd: - port_file.write_text(str(httpd.server_port), encoding="utf-8") - httpd.serve_forever() - PY + server_url="http://127.0.0.1:18080" + archive_url="${server_url}/download/v${version}/${archive}" + python3 -m http.server 18080 --directory "$fixture_root" >"$server_log" 2>&1 & server_pid=$! trap 'kill "$server_pid" 2>/dev/null || true' EXIT server_ready=0 - server_url="" - archive_url="" - for attempt in $(seq 1 40); do + for attempt in $(seq 1 120); do if ! kill -0 "$server_pid" 2>/dev/null; then echo "Fixture HTTP server exited before serving the test archive." >&2 sed 's/^/ /' "$server_log" >&2 || true exit 1 fi - if [ -s "$server_port_file" ]; then - server_url="http://127.0.0.1:$(cat "$server_port_file")" - archive_url="${server_url}/download/v${version}/${archive}" - fi - if [ -n "$archive_url" ] && curl -fsS --head --connect-timeout 2 "$archive_url" >/dev/null 2>&1; then + if curl -fsS --head --connect-timeout 2 "$archive_url" >/dev/null 2>&1; then server_ready=1 break fi sleep 0.25 done if [ "$server_ready" != "1" ]; then - if [ -z "$archive_url" ]; then - archive_url="download/v${version}/${archive}" - fi echo "Fixture HTTP server did not serve $archive_url." >&2 sed 's/^/ /' "$server_log" >&2 || true exit 1 @@ -200,7 +187,13 @@ jobs: # restriction (test only). export INSTALLER_CURL_PROTO_OPTS="--proto =http,https" + # Pin the source to github so this test only exercises the fixture + # (RELEASES_DOWNLOAD_BASE_URL) path; auto mode would fast-fail a + # transient fixture hiccup and divert to the real dl.agora.io mirror. + # github mode also uses the retry profile, restoring pre-fallback + # resilience. VERSION="$version" \ + AGORA_INSTALL_SOURCE=github \ RELEASES_DOWNLOAD_BASE_URL="${server_url}/download" \ RELEASES_PAGE_URL="$server_url" \ INSTALL_DIR="$install_dir" \ @@ -210,6 +203,7 @@ jobs: printf '%064d %s\n' 0 "$archive" > "$download_dir/checksums.txt" if VERSION="$version" \ + AGORA_INSTALL_SOURCE=github \ RELEASES_DOWNLOAD_BASE_URL="${server_url}/download" \ RELEASES_PAGE_URL="$server_url" \ INSTALL_DIR="$bad_install_dir" \ @@ -218,6 +212,78 @@ jobs: exit 1 fi + - name: Smoke test installer S3 fallback (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + version='0.0.0-ci' + arch="$(uname -m)"; case "$arch" in x86_64|amd64) arch=amd64;; aarch64|arm64) arch=arm64;; esac + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + archive="agora-cli_v${version}_${os}_${arch}.tar.gz" + + root=".tmp/fallback-fixture" + rel="${root}/releases/v${version}" + install_dir="$PWD/.tmp/fallback-bin" + rm -rf "$root" "$install_dir" + mkdir -p "$rel" "$install_dir" + + cp dist/agora "$root/agora" + tar -C "$root" -czf "${rel}/${archive}" agora + if command -v sha256sum >/dev/null 2>&1; then + ( cd "$rel" && sha256sum "$archive" > checksums.txt ) + else + ( cd "$rel" && shasum -a 256 "$archive" > checksums.txt ) + fi + printf '{"tag_name":"v%s","version":"%s"}' "$version" "$version" > "${root}/latest.json" + + server_log="${root}/http.log" + python3 -m http.server 18082 --directory "$root" >"$server_log" 2>&1 & + server_pid=$! + trap 'kill "$server_pid" 2>/dev/null || true' EXIT + server_ready=0 + for _ in $(seq 1 120); do + if ! kill -0 "$server_pid" 2>/dev/null; then + echo "Fixture HTTP server exited before becoming ready." >&2 + sed 's/^/ /' "$server_log" >&2 || true + exit 1 + fi + if curl -fsS --head --connect-timeout 2 "http://127.0.0.1:18082/latest.json" >/dev/null 2>&1; then + server_ready=1; break + fi + sleep 0.25 + done + if [ "$server_ready" != "1" ]; then + echo "Fixture HTTP server did not become ready." >&2 + sed 's/^/ /' "$server_log" >&2 || true + exit 1 + fi + + export INSTALLER_CURL_PROTO_OPTS="--proto =http,https" + GITHUB_API_URL="http://127.0.0.1:1/api" \ + RELEASES_DOWNLOAD_BASE_URL="http://127.0.0.1:1/download" \ + S3_DOWNLOAD_BASE_URL="http://127.0.0.1:18082/releases" \ + S3_LATEST_URL="http://127.0.0.1:18082/latest.json" \ + RELEASES_PAGE_URL="http://127.0.0.1:18082" \ + INSTALL_DIR="$install_dir" \ + sh ./install.sh --force + + "$install_dir/agora" --help >/dev/null + echo "Fallback install succeeded via mock S3." + + printf '%064d %s\n' 0 "$archive" > "${rel}/checksums.txt" + if GITHUB_API_URL="http://127.0.0.1:1/api" \ + RELEASES_DOWNLOAD_BASE_URL="http://127.0.0.1:1/download" \ + S3_DOWNLOAD_BASE_URL="http://127.0.0.1:18082/releases" \ + S3_LATEST_URL="http://127.0.0.1:18082/latest.json" \ + RELEASES_PAGE_URL="http://127.0.0.1:18082" \ + INSTALL_DIR="$PWD/.tmp/fallback-bin-bad" \ + sh ./install.sh --force; then + echo "Expected checksum verification to fail on the mirrored archive." >&2 + exit 1 + fi + echo "Checksum verification correctly rejected the tampered mirror archive." + - name: Smoke test PowerShell installer if: runner.os == 'Windows' shell: pwsh @@ -247,7 +313,7 @@ jobs: $server = Start-Process -FilePath python -ArgumentList '-m', 'http.server', '18081', '--directory', $fixtureRoot -RedirectStandardOutput $serverOutLog -RedirectStandardError $serverErrLog -PassThru $archiveUrl = "http://127.0.0.1:18081/download/v$version/$archive" $serverReady = $false - for ($attempt = 1; $attempt -le 20; $attempt++) { + for ($attempt = 1; $attempt -le 60; $attempt++) { if ($server.HasExited) { if (Test-Path -LiteralPath $serverOutLog) { Get-Content -Path $serverOutLog | ForEach-Object { Write-Host $_ } } if (Test-Path -LiteralPath $serverErrLog) { Get-Content -Path $serverErrLog | ForEach-Object { Write-Host $_ } } @@ -271,6 +337,9 @@ jobs: try { $env:VERSION = $version + # Pin source to github so this test only exercises the fixture path + # (auto mode could divert a transient hiccup to the real mirror). + $env:AGORA_INSTALL_SOURCE = 'github' $env:RELEASES_DOWNLOAD_BASE_URL = 'http://127.0.0.1:18081/download' $env:RELEASES_PAGE_URL = 'http://127.0.0.1:18081' @@ -297,5 +366,64 @@ jobs: $global:LASTEXITCODE = 0 } finally { Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue - Remove-Item Env:VERSION, Env:RELEASES_DOWNLOAD_BASE_URL, Env:RELEASES_PAGE_URL -ErrorAction SilentlyContinue + Remove-Item Env:VERSION, Env:AGORA_INSTALL_SOURCE, Env:RELEASES_DOWNLOAD_BASE_URL, Env:RELEASES_PAGE_URL -ErrorAction SilentlyContinue + } + + - name: Smoke test PowerShell installer S3 fallback + if: runner.os == 'Windows' + shell: pwsh + run: | + $version = '0.0.0-ci' + $arch = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()) { + 'x64' { 'amd64' } 'arm64' { 'arm64' } default { throw "Unsupported CI arch" } + } + $root = Join-Path $PWD '.tmp/fallback-fixture' + $rel = Join-Path $root "releases/v$version" + $installDir = Join-Path $PWD '.tmp/fallback-bin' + $archive = "agora-cli_v$version" + "_windows_${arch}.zip" + Remove-Item -Recurse -Force $root, $installDir -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $rel, $installDir | Out-Null + + Compress-Archive -Path (Join-Path $PWD 'dist/agora.exe') -DestinationPath (Join-Path $rel $archive) -Force + $hash = (Get-FileHash -Path (Join-Path $rel $archive) -Algorithm SHA256).Hash.ToLowerInvariant() + Set-Content -Path (Join-Path $rel 'checksums.txt') -Value "$hash $archive" + Set-Content -NoNewline -Path (Join-Path $root 'latest.json') -Value ('{"tag_name":"v' + $version + '","version":"' + $version + '"}') + + $server = Start-Process -FilePath python -ArgumentList '-m', 'http.server', '18083', '--directory', $root -PassThru + try { + $ready = $false + for ($i = 0; $i -lt 120; $i++) { + if ($server.HasExited) { throw 'Fixture HTTP server exited before becoming ready.' } + try { if ((Invoke-WebRequest -Uri 'http://127.0.0.1:18083/latest.json' -UseBasicParsing).StatusCode -eq 200) { $ready = $true; break } } catch { Start-Sleep -Milliseconds 250 } + } + if (-not $ready) { throw 'fixture server not ready at http://127.0.0.1:18083/latest.json' } + + $env:GITHUB_API_URL = 'http://127.0.0.1:1/api' + $env:RELEASES_DOWNLOAD_BASE_URL = 'http://127.0.0.1:1/download' + $env:S3_DOWNLOAD_BASE_URL = 'http://127.0.0.1:18083/releases' + $env:S3_LATEST_URL = 'http://127.0.0.1:18083/latest.json' + $env:RELEASES_PAGE_URL = 'http://127.0.0.1:18083' + + & ./install.ps1 -InstallDir $installDir + if ($LASTEXITCODE -ne 0) { throw "install.ps1 fallback failed: $LASTEXITCODE" } + & (Join-Path $installDir 'agora.exe') --help *> $null + Write-Host 'Fallback install succeeded via mock S3.' + + # Checksum verification must still fire on S3-served bytes: corrupt + # the mirror's checksums.txt and assert install.ps1 refuses. + Set-Content -Path (Join-Path $rel 'checksums.txt') -Value ('0' * 64 + " $archive") + $badInstallDir = Join-Path $PWD '.tmp/fallback-bin-bad' + $previousNativePreference = $PSNativeCommandUseErrorActionPreference + $PSNativeCommandUseErrorActionPreference = $false + pwsh -NoProfile -ExecutionPolicy Bypass -File ./install.ps1 -InstallDir $badInstallDir + $badExitCode = $LASTEXITCODE + $PSNativeCommandUseErrorActionPreference = $previousNativePreference + if ($badExitCode -eq 0) { + throw 'Expected checksum verification to fail on the mirrored archive.' + } + $global:LASTEXITCODE = 0 + Write-Host 'Checksum verification correctly rejected the tampered mirror archive.' + } finally { + Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue + Remove-Item Env:GITHUB_API_URL, Env:RELEASES_DOWNLOAD_BASE_URL, Env:S3_DOWNLOAD_BASE_URL, Env:S3_LATEST_URL, Env:RELEASES_PAGE_URL -ErrorAction SilentlyContinue } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19e4f1d..226671a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -366,3 +366,136 @@ jobs: sleep "$delay" delay=$((delay * 2)) done + + mirror-to-s3: + name: Mirror release to S3 + needs: [goreleaser] + if: github.event_name == 'push' && needs.goreleaser.result == 'success' + runs-on: ubuntu-latest + env: + AWS_REGION: us-east-1 + S3_BUCKET: dl-agora-io + S3_PREFIX: cli + CF_DISTRIBUTION_ID: E2U1WWAZBG33XY + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Resolve tag/version + id: ver + shell: bash + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + + - name: Download release artifacts + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p dist + gh release download "${{ steps.ver.outputs.tag }}" \ + --pattern "agora-cli_*_linux_*.tar.gz" \ + --pattern "agora-cli_*_darwin_*.tar.gz" \ + --pattern "agora-cli_*_windows_*.zip" \ + --pattern "checksums.txt" \ + --dir dist + # Sigstore bundle is best-effort; its absence must not block mirroring. + gh release download "${{ steps.ver.outputs.tag }}" \ + --pattern "checksums.txt.sigstore.json" \ + --dir dist || echo "::warning::checksums.txt.sigstore.json not present in release; skipping." + echo "Downloaded:"; ls -la dist/ + + - name: Verify SHA-256 checksums before upload + shell: bash + run: | + set -euo pipefail + if [ ! -f dist/checksums.txt ]; then + echo "::error::checksums.txt missing; refusing to mirror unverified binaries." + exit 1 + fi + cd dist + while IFS= read -r line; do + [ -z "$line" ] && continue + expected="$(echo "$line" | awk '{print $1}')" + file="$(echo "$line" | awk '{print $2}')" + case "$file" in + agora-cli_*_linux_*.tar.gz|agora-cli_*_darwin_*.tar.gz|agora-cli_*_windows_*.zip) ;; + *) continue ;; + esac + [ -f "$file" ] || { echo "::error::missing $file"; exit 1; } + actual="$(sha256sum "$file" | awk '{print $1}')" + [ "$expected" = "$actual" ] || { echo "::error::checksum mismatch for $file"; exit 1; } + echo " ok: $file" + done < checksums.txt + echo "All archives verified." + + - name: Determine prerelease flag + id: pre + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + is_pre="$(gh release view "${{ steps.ver.outputs.tag }}" --json isPrerelease --jq '.isPrerelease')" + echo "is_prerelease=${is_pre}" >> "$GITHUB_OUTPUT" + echo "Prerelease: ${is_pre}" + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Upload versioned artifacts (immutable) + shell: bash + run: | + set -euo pipefail + dest="s3://${S3_BUCKET}/${S3_PREFIX}/releases/${{ steps.ver.outputs.tag }}/" + aws s3 cp dist/ "$dest" --recursive \ + --exclude "*" \ + --include "agora-cli_*_linux_*.tar.gz" \ + --include "agora-cli_*_darwin_*.tar.gz" \ + --include "agora-cli_*_windows_*.zip" \ + --include "checksums.txt" \ + --include "checksums.txt.sigstore.json" \ + --cache-control "public, max-age=31536000, immutable" + echo "Uploaded versioned artifacts to $dest" + + - name: Upload installer scripts (mutable) + shell: bash + run: | + set -euo pipefail + base="s3://${S3_BUCKET}/${S3_PREFIX}" + aws s3 cp install.sh "${base}/install.sh" --cache-control "public, max-age=300" --content-type "text/x-shellscript" + aws s3 cp install.ps1 "${base}/install.ps1" --cache-control "public, max-age=300" --content-type "text/plain" + echo "Uploaded installer scripts." + + - name: Update latest.json pointer (stable releases only) + if: steps.pre.outputs.is_prerelease == 'false' + shell: bash + run: | + set -euo pipefail + v="${{ steps.ver.outputs.version }}" + printf '{"tag_name":"v%s","version":"%s"}' "$v" "$v" > latest.json + cat latest.json; echo + aws s3 cp latest.json "s3://${S3_BUCKET}/${S3_PREFIX}/latest.json" \ + --cache-control "public, max-age=300" --content-type "application/json" + echo "Updated latest.json -> v${v}" + + - name: Invalidate CloudFront for mutable paths + shell: bash + run: | + set -euo pipefail + paths="/${S3_PREFIX}/install.sh /${S3_PREFIX}/install.ps1" + if [ "${{ steps.pre.outputs.is_prerelease }}" = "false" ]; then + paths="${paths} /${S3_PREFIX}/latest.json" + fi + # shellcheck disable=SC2086 + aws cloudfront create-invalidation \ + --distribution-id "${CF_DISTRIBUTION_ID}" \ + --paths $paths + echo "Invalidated: ${paths}" diff --git a/README.md b/README.md index 698c59f..9b41ede 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ irm https://agoraio.github.io/cli/install.ps1 | iex Locked-down environments that block `curl | sh` can use npm, download a release archive from GitHub, or mirror the binary internally. Every release includes `checksums.txt`, a Cosign keyless signature, and an SBOM; see [docs/install.md](docs/install.md#enterprise--locked-down-environments) for manual tarball, checksum, and Cosign verification steps. +When GitHub is unreachable (blocked region, API rate limit, transient outage), the installer automatically falls back to the Agora mirror at `dl.agora.io` (CloudFront), and downloads are still verified against `checksums.txt`. See [Restricted networks](#restricted-networks-github-blocked) below. + Notes: - The shell installer supports macOS, Linux, and Windows POSIX shells such as Git Bash. Use `install.ps1` for native PowerShell installs on Windows. @@ -55,6 +57,20 @@ curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 | iex ``` +### Restricted networks (GitHub blocked) + +The installer fetches from GitHub by default and automatically falls back to the Agora mirror at `dl.agora.io` on failure. Where GitHub is **fully** blocked, the GitHub-hosted script URLs above are unreachable too — fetch the script from the mirror and skip GitHub entirely with `AGORA_INSTALL_SOURCE=s3`: + +```bash +# macOS, Linux, and Windows POSIX shells +curl -fsSL https://dl.agora.io/cli/install.sh | AGORA_INSTALL_SOURCE=s3 sh + +# Windows PowerShell +$env:AGORA_INSTALL_SOURCE = 's3'; irm https://dl.agora.io/cli/install.ps1 | iex +``` + +Downloads are still SHA-256 verified against `checksums.txt` regardless of source. `AGORA_INSTALL_SOURCE` accepts `auto` (default; GitHub then mirror), `github`, or `s3`. See [docs/install.md](docs/install.md#mirror-fallback-for-restricted-networks) for details. + ### Build from source ```bash diff --git a/RELEASING.md b/RELEASING.md index 6db0567..0d9610d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -82,6 +82,34 @@ Before tagging a real npm release, confirm: Homebrew and Scoop are not part of the current GoReleaser config. Add `brews:` / `scoops:` blocks before documenting them as automated channels. +## S3 mirror (dl.agora.io) + +On every tag push, the `mirror-to-s3` job in `release.yml` copies the release to +the CloudFront-fronted S3 mirror so installers work where GitHub is blocked: + +- Versioned artifacts → `s3://dl-agora-io/cli/releases/v/` + (archives, `checksums.txt`, `checksums.txt.sigstore.json`; cached immutable). +- `install.sh` / `install.ps1` → `s3://dl-agora-io/cli/` (short cache). +- `latest.json` → `s3://dl-agora-io/cli/latest.json` (stable releases only). +- CloudFront (`E2U1WWAZBG33XY`) invalidation for the mutable paths. + +### Required GitHub secrets + +- `AWS_ACCESS_KEY_ID` +- `AWS_SECRET_ACCESS_KEY` + +### Minimal IAM policy for those keys + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { "Effect": "Allow", "Action": "s3:PutObject", "Resource": "arn:aws:s3:::dl-agora-io/cli/*" }, + { "Effect": "Allow", "Action": "cloudfront:CreateInvalidation", "Resource": "arn:aws:cloudfront::*:distribution/E2U1WWAZBG33XY" } + ] +} +``` + ## Distribution Channels | Channel | How | diff --git a/docs/install.md b/docs/install.md index a89c7bc..d669895 100644 --- a/docs/install.md +++ b/docs/install.md @@ -193,6 +193,44 @@ Advanced or test overrides supported by both direct installers: - `GITHUB_API_URL`: alternate API base URL. - `RELEASES_DOWNLOAD_BASE_URL`: alternate release download base URL. - `RELEASES_PAGE_URL`: alternate releases page URL used in error messages. +- `AGORA_INSTALL_SOURCE`: control the download source (`auto`, `github`, or `s3`; see [Mirror fallback](#mirror-fallback-for-restricted-networks)). +- `S3_DOWNLOAD_BASE_URL`: alternate S3/mirror base URL for versioned release archives (default `https://dl.agora.io/cli/releases`). +- `S3_LATEST_URL`: alternate URL for the mirror's `latest.json` (default `https://dl.agora.io/cli/latest.json`). + +## Mirror fallback for restricted networks + +The installers download from GitHub by default. When GitHub is unreachable +(blocked region, API rate limit, transient outage), they automatically fall +back to the Agora mirror at `https://dl.agora.io/cli` (CloudFront + S3). +Downloads are still verified against `checksums.txt` regardless of source. + +Control the source with `AGORA_INSTALL_SOURCE`: + +| Value | Behavior | +|-------|----------| +| `auto` (default) | GitHub first, mirror fallback | +| `github` | GitHub only, no fallback | +| `s3` | Mirror only, skip GitHub entirely | + +Blocked-region one-liner (skips the GitHub timeout entirely): + +```sh +curl -fsSL https://dl.agora.io/cli/install.sh | AGORA_INSTALL_SOURCE=s3 sh +``` + +Windows: + +```powershell +$env:AGORA_INSTALL_SOURCE = 's3'; irm https://dl.agora.io/cli/install.ps1 | iex +``` + +Advanced overrides: `S3_DOWNLOAD_BASE_URL` (default +`https://dl.agora.io/cli/releases`) and `S3_LATEST_URL` (default +`https://dl.agora.io/cli/latest.json`). + +**Limitation:** `--prerelease` and version listing require GitHub; the mirror +only tracks the latest stable release. In a fully blocked region, pin an +explicit `--version` to install from the mirror. ## Exit Codes diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 86c0fc6..0824c16 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -82,6 +82,29 @@ curl -fsSL @@CLI_INSTALL_SH_URL@@ | sh Use `--force` only when you intentionally want two installs and understand that the first `agora` on `PATH` wins. +## Installer can't reach GitHub (blocked region or rate limit) + +The installer downloads from GitHub by default and automatically falls back to +the Agora mirror at `dl.agora.io` when GitHub is unreachable or rate-limited; +downloads stay SHA-256 verified either way. Where GitHub is **fully** blocked, +the GitHub-hosted script URL is unreachable too, so fetch the script from the +mirror and skip GitHub entirely: + +```sh +curl -fsSL https://dl.agora.io/cli/install.sh | AGORA_INSTALL_SOURCE=s3 sh +``` + +PowerShell: + +```powershell +$env:AGORA_INSTALL_SOURCE = 's3'; irm https://dl.agora.io/cli/install.ps1 | iex +``` + +`AGORA_INSTALL_SOURCE` accepts `auto` (default), `github`, or `s3`. Note that +`--prerelease` and version listing require GitHub; pin an explicit `--version` +to install a specific release from the mirror. See +[Install](install.html#mirror-fallback-for-restricted-networks) for details. + ## `agora init` or `agora quickstart create` fails on `git clone` The CLI shells out to `git clone` for quickstarts. Most failures map to a stable error code: diff --git a/install.ps1 b/install.ps1 index 09ff8e9..afa5288 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,6 +11,10 @@ # Quick start: # irm https://agoraio.github.io/cli/install.ps1 | iex # +# Restricted networks (GitHub blocked): auto-falls back to the dl.agora.io +# mirror; where GitHub is fully blocked, fetch the script from the mirror too: +# $env:AGORA_INSTALL_SOURCE='s3'; irm https://dl.agora.io/cli/install.ps1 | iex +# # Pin a version: # $env:VERSION = '0.2.0'; & ([scriptblock]::Create((irm .../install.ps1))) # @@ -48,6 +52,9 @@ $InstallReceiptFileName = 'agora.install.json' $GitHubApiUrl = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { 'https://api.github.com' } $ReleasesDownloadBaseUrl = if ($env:RELEASES_DOWNLOAD_BASE_URL) { $env:RELEASES_DOWNLOAD_BASE_URL } else { "https://github.com/$GitHubRepo/releases/download" } +$S3DownloadBaseUrl = if ($env:S3_DOWNLOAD_BASE_URL) { $env:S3_DOWNLOAD_BASE_URL } else { 'https://dl.agora.io/cli/releases' } +$S3LatestUrl = if ($env:S3_LATEST_URL) { $env:S3_LATEST_URL } else { 'https://dl.agora.io/cli/latest.json' } +$AgoraInstallSource = if ($env:AGORA_INSTALL_SOURCE) { $env:AGORA_INSTALL_SOURCE } else { 'auto' } $ReleasesPageUrl = if ($env:RELEASES_PAGE_URL) { $env:RELEASES_PAGE_URL } else { "https://github.com/$GitHubRepo/releases" } $DocsUrl = if ($env:DOCS_URL) { $env:DOCS_URL } else { "https://github.com/$GitHubRepo#readme" } $AuthToken = if ($env:GITHUB_TOKEN) { $env:GITHUB_TOKEN } elseif ($env:GH_TOKEN) { $env:GH_TOKEN } else { $null } @@ -127,12 +134,30 @@ function Resolve-Version { return } - $latestUrl = "$($GitHubApiUrl.TrimEnd('/'))/repos/$GitHubRepo/releases/latest" + $release = $null + if ($AgoraInstallSource -ne 's3') { + $latestUrl = "$($GitHubApiUrl.TrimEnd('/'))/repos/$GitHubRepo/releases/latest" + try { + if ($AgoraInstallSource -eq 'github') { + $release = Invoke-RestMethod -Uri $latestUrl -Headers (Get-AuthHeaders) + } else { + $release = Invoke-RestMethod -Uri $latestUrl -Headers (Get-AuthHeaders) -TimeoutSec 5 + } + } catch { + if ($AgoraInstallSource -eq 'github') { + Fail "Could not resolve the latest release from GitHub. Set VERSION explicitly or provide GITHUB_TOKEN / GH_TOKEN if you are hitting rate limits. Release page: $ReleasesPageUrl" -ExitCode $EXIT_NETWORK + } + Write-Info 'GitHub unreachable; retrying via dl.agora.io mirror...' + } + } - try { - $release = Invoke-RestMethod -Uri $latestUrl -Headers (Get-AuthHeaders) - } catch { - Fail "Could not resolve the latest release. Set VERSION explicitly or provide GITHUB_TOKEN / GH_TOKEN if you are hitting rate limits. Release page: $ReleasesPageUrl" -ExitCode $EXIT_NETWORK + if (-not $release) { + try { + $release = Invoke-RestMethod -Uri $S3LatestUrl -MaximumRetryCount 3 -RetryIntervalSec 2 + } catch { + $sources = if ($AgoraInstallSource -ne 's3') { 'GitHub or the dl.agora.io mirror' } else { 'the dl.agora.io mirror' } + Fail "Could not resolve the latest version from $sources. Pin VERSION explicitly to install from the mirror." -ExitCode $EXIT_NETWORK + } } $script:Version = Normalize-Version $release.tag_name @@ -153,6 +178,37 @@ function Download-File { } } +function Invoke-DownloadWithFallback { + param( + [Parameter(Mandatory = $true)][string]$GitHubUrl, + [Parameter(Mandatory = $true)][string]$S3Url, + [Parameter(Mandatory = $true)][string]$Destination + ) + + if ($AgoraInstallSource -ne 's3') { + try { + if ($AgoraInstallSource -eq 'github') { + Invoke-WebRequest -Uri $GitHubUrl -OutFile $Destination -Headers (Get-AuthHeaders) + } else { + Invoke-WebRequest -Uri $GitHubUrl -OutFile $Destination -Headers (Get-AuthHeaders) -TimeoutSec 5 + } + return + } catch { + if ($AgoraInstallSource -eq 'github') { + Fail "Failed to download $GitHubUrl`nRelease page: $ReleasesPageUrl`nCheck your network or proxy settings, or try again with VERSION pinned." -ExitCode $EXIT_NETWORK + } + Write-Info 'GitHub unreachable; retrying via dl.agora.io mirror...' + } + } + + try { + Invoke-WebRequest -Uri $S3Url -OutFile $Destination -MaximumRetryCount 3 -RetryIntervalSec 2 + } catch { + $sources = if ($AgoraInstallSource -ne 's3') { 'GitHub and the dl.agora.io mirror' } else { 'the dl.agora.io mirror' } + Fail "Failed to download from ${sources}: $S3Url`nRelease page: $ReleasesPageUrl" -ExitCode $EXIT_NETWORK + } +} + function Get-ExpectedChecksum { param( [Parameter(Mandatory = $true)][string]$ChecksumsPath, @@ -440,6 +496,10 @@ if ($Uninstall) { exit $EXIT_OK } +if ($AgoraInstallSource -notin @('auto', 'github', 's3')) { + Fail "AGORA_INSTALL_SOURCE must be one of: auto, github, s3 (got '$AgoraInstallSource')." -ExitCode $EXIT_USAGE +} + $Version = Normalize-Version $Version $arch = Resolve-Architecture $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("agora-install-" + [System.Guid]::NewGuid().ToString('N')) @@ -453,7 +513,9 @@ try { $sourceBinary = Join-Path $extractDir 'agora.exe' $tempDestinationBinary = Join-Path $InstallDir ('.agora.tmp.' + [System.Guid]::NewGuid().ToString('N') + '.exe') $archiveUrl = "$($ReleasesDownloadBaseUrl.TrimEnd('/'))/v$Version/$fileName" + $archiveUrlS3 = "$($S3DownloadBaseUrl.TrimEnd('/'))/v$Version/$fileName" $checksumsUrl = "$($ReleasesDownloadBaseUrl.TrimEnd('/'))/v$Version/checksums.txt" + $checksumsUrlS3 = "$($S3DownloadBaseUrl.TrimEnd('/'))/v$Version/checksums.txt" Show-ExistingInstall @@ -475,8 +537,8 @@ try { Write-Info "Installing agora $Version (windows/$arch) -> $destinationBinary" - Download-File -Url $archiveUrl -Destination $archivePath - Download-File -Url $checksumsUrl -Destination $checksumsPath + Invoke-DownloadWithFallback -GitHubUrl $archiveUrl -S3Url $archiveUrlS3 -Destination $archivePath + Invoke-DownloadWithFallback -GitHubUrl $checksumsUrl -S3Url $checksumsUrlS3 -Destination $checksumsPath $expectedChecksum = Get-ExpectedChecksum -ChecksumsPath $checksumsPath -FileName $fileName if (-not $expectedChecksum) { diff --git a/install.sh b/install.sh index ebf287b..280a016 100755 --- a/install.sh +++ b/install.sh @@ -4,6 +4,10 @@ # Quick start: # curl -fsSL https://agoraio.github.io/cli/install.sh | sh # +# Restricted networks (GitHub blocked): auto-falls back to the dl.agora.io +# mirror; where GitHub is fully blocked, fetch the script from the mirror too: +# curl -fsSL https://dl.agora.io/cli/install.sh | AGORA_INSTALL_SOURCE=s3 sh +# # Pin a version: # curl -fsSL .../install.sh | sh -s -- --version 0.1.4 # @@ -36,6 +40,9 @@ VERSION="${VERSION:-}" SUDO="${SUDO:-sudo}" GITHUB_API_URL="${GITHUB_API_URL:-https://api.github.com}" RELEASES_DOWNLOAD_BASE_URL="${RELEASES_DOWNLOAD_BASE_URL:-https://github.com/${GITHUB_REPO}/releases/download}" +S3_DOWNLOAD_BASE_URL="${S3_DOWNLOAD_BASE_URL:-https://dl.agora.io/cli/releases}" +S3_LATEST_URL="${S3_LATEST_URL:-https://dl.agora.io/cli/latest.json}" +AGORA_INSTALL_SOURCE="${AGORA_INSTALL_SOURCE:-auto}" RELEASES_PAGE_URL="${RELEASES_PAGE_URL:-https://github.com/${GITHUB_REPO}/releases}" DOCS_URL="${DOCS_URL:-https://github.com/${GITHUB_REPO}#readme}" ISSUES_URL="${ISSUES_URL:-https://github.com/${GITHUB_REPO}/issues}" @@ -236,6 +243,12 @@ ${BOLD}Environment:${RESET} NO_COLOR Disable colored output (any non-empty value). GITHUB_API_URL Override GitHub API base URL. RELEASES_DOWNLOAD_BASE_URL Override release download base URL. + S3_DOWNLOAD_BASE_URL Override the S3/CloudFront mirror download base + (default: https://dl.agora.io/cli/releases). + S3_LATEST_URL Override the mirror's latest-version pointer + (default: https://dl.agora.io/cli/latest.json). + AGORA_INSTALL_SOURCE auto (GitHub then mirror), github (GitHub only), + or s3 (mirror only). Default: auto. RELEASES_PAGE_URL Override release page URL (used in messages). DOCS_URL Override docs URL (used in next-steps footer). ISSUES_URL Override issues URL (used in error messages). @@ -386,6 +399,15 @@ normalize_version() { VERSION=$(printf '%s' "$VERSION" | sed 's/^v//') } +validate_install_source() { + case "$AGORA_INSTALL_SOURCE" in + auto|github|s3) ;; + *) + die "AGORA_INSTALL_SOURCE must be one of: auto, github, s3 (got '${AGORA_INSTALL_SOURCE}')." "$EXIT_USAGE" + ;; + esac +} + platform_default_install_dir() { case "$OS" in windows) printf '%s\n' "$HOME/bin" ;; @@ -455,7 +477,28 @@ detect_downloader() { # non-HTTPS fixtures (e.g. a local HTTP server). Not intended for end users. CURL_PROTO_OPTS="${INSTALLER_CURL_PROTO_OPTS:---proto =https --tlsv1.2}" CURL_RETRY_OPTS="--retry 3 --retry-delay 2 --retry-connrefused --connect-timeout 10 --max-time 300" +# Fast-fail profile for the GitHub attempt: short timeout, no retries, so the +# S3 fallback kicks in quickly in blocked regions instead of stalling. +CURL_FASTFAIL_OPTS="--connect-timeout 5 --max-time 120" +WGET_RETRY_OPTS="--tries=3 --timeout=30" +WGET_FASTFAIL_OPTS="--tries=1 --timeout=5" +# Active profiles, switched by set_fetch_profile. Default = resilient (retry), +# which preserves prior behavior for any single-source download_or_fail caller. curl_common_opts="$CURL_PROTO_OPTS $CURL_RETRY_OPTS -fL" +wget_common_opts="$WGET_RETRY_OPTS" + +set_fetch_profile() { + case "$1" in + fastfail) + curl_common_opts="$CURL_PROTO_OPTS $CURL_FASTFAIL_OPTS -fL" + wget_common_opts="$WGET_FASTFAIL_OPTS" + ;; + *) + curl_common_opts="$CURL_PROTO_OPTS $CURL_RETRY_OPTS -fL" + wget_common_opts="$WGET_RETRY_OPTS" + ;; + esac +} download_quiet() { url=$1 @@ -464,19 +507,22 @@ download_quiet() { if [ "$DOWNLOAD_TOOL" = "wget" ]; then if [ "$mode" = "api" ] && [ -n "$AUTH_TOKEN" ]; then - wget -q -O "$output" \ + # shellcheck disable=SC2086 + wget $wget_common_opts -q -O "$output" \ --header='Accept: application/vnd.github+json' \ --header="Authorization: Bearer $AUTH_TOKEN" \ "$url" return $? fi if [ "$mode" = "api" ]; then - wget -q -O "$output" \ + # shellcheck disable=SC2086 + wget $wget_common_opts -q -O "$output" \ --header='Accept: application/vnd.github+json' \ "$url" return $? fi - wget -q -O "$output" "$url" + # shellcheck disable=SC2086 + wget $wget_common_opts -q -O "$output" "$url" return $? fi @@ -505,10 +551,12 @@ download_archive() { if [ "$DOWNLOAD_TOOL" = "wget" ]; then if [ -t 1 ] && [ "$QUIET" = "0" ]; then - wget -O "$output" "$url" + # shellcheck disable=SC2086 + wget $wget_common_opts -O "$output" "$url" return $? fi - wget -q -O "$output" "$url" + # shellcheck disable=SC2086 + wget $wget_common_opts -q -O "$output" "$url" return $? fi @@ -521,6 +569,85 @@ download_archive() { curl $curl_common_opts -sS "$url" -o "$output" } +# Single download attempt against one URL using the active fetch profile. +# Returns the downloader exit status; never exits the process. +_fetch_once() { + url=$1 + output=$2 + mode=${3:-download} + if [ "$mode" = "archive" ]; then + download_archive "$url" "$output" + else + download_quiet "$url" "$output" "$mode" + fi +} + +# Fetch a resource that exists on both GitHub and the S3 mirror, honoring +# AGORA_INSTALL_SOURCE. auto: GitHub (fast-fail) then S3 (retry). github: GitHub +# only. s3: S3 only. Dies (EXIT_NETWORK) only after every selected source fails. +download_with_fallback() { + gh_url=$1 + s3_url=$2 + output=$3 + mode=${4:-download} + + # --- GitHub attempt (skipped when source=s3) --- + if [ "$AGORA_INSTALL_SOURCE" != "s3" ] && [ -n "$gh_url" ]; then + verbose "GET $gh_url (github)" + if [ "$AGORA_INSTALL_SOURCE" = "github" ]; then + set_fetch_profile retry + else + set_fetch_profile fastfail + fi + if _fetch_once "$gh_url" "$output" "$mode"; then + set_fetch_profile retry + return 0 + fi + set_fetch_profile retry + if [ "$AGORA_INSTALL_SOURCE" = "github" ]; then + err "Download failed from GitHub: $gh_url" + warn "Release page: ${RELEASES_PAGE_URL}" + if [ "$mode" = "api" ]; then + die "Could not reach the GitHub API. Set --version explicitly, or provide GITHUB_TOKEN / GH_TOKEN if you are hitting rate limits." "$EXIT_NETWORK" + fi + die "GitHub download failed and AGORA_INSTALL_SOURCE=github disables the mirror. Unset it to allow the dl.agora.io mirror, or pin --version." "$EXIT_NETWORK" + fi + say "GitHub unreachable; retrying via dl.agora.io mirror..." + fi + + # --- S3 attempt --- + if [ -n "$s3_url" ]; then + verbose "GET $s3_url (mirror)" + set_fetch_profile retry + # The mirror is a plain static-file host, not the GitHub API. Never send + # GitHub API auth/Accept headers to it (that would leak the token). Downgrade + # api mode to a plain download for the mirror leg; archive mode is preserved + # so the progress bar still works for the archive download. + s3_mode=$mode + if [ "$s3_mode" = "api" ]; then + s3_mode=download + fi + if _fetch_once "$s3_url" "$output" "$s3_mode"; then + return 0 + fi + fi + + if [ "$AGORA_INSTALL_SOURCE" = "s3" ]; then + err "Download failed from the dl.agora.io mirror." + warn "Release page: ${RELEASES_PAGE_URL}" + if [ "$mode" = "api" ]; then + die "Could not resolve the version from the dl.agora.io mirror. Pin --version explicitly." "$EXIT_NETWORK" + fi + die "Network or proxy issue reaching the dl.agora.io mirror. Re-run with --verbose, or pin --version." "$EXIT_NETWORK" + fi + err "Download failed from GitHub and the dl.agora.io mirror." + warn "Release page: ${RELEASES_PAGE_URL}" + if [ "$mode" = "api" ]; then + die "Could not resolve the version from GitHub or the mirror. Pin --version explicitly to install from the mirror." "$EXIT_NETWORK" + fi + die "Network or proxy issue on both GitHub and the mirror. Re-run with --verbose, or pin --version." "$EXIT_NETWORK" +} + download_or_fail() { url=$1 output=$2 @@ -845,12 +972,16 @@ first_tag_name_from_json() { resolve_version() { if [ "$PRERELEASE" = "1" ]; then + # Prereleases are GitHub-only: the S3 mirror exposes a stable latest.json + # pointer, not a prerelease index (documented limitation). url="${GITHUB_API_URL%/}/repos/${GITHUB_REPO}/releases?per_page=10" + json="${TMP}/latest.json" + download_or_fail "$url" "$json" api else - url="${GITHUB_API_URL%/}/repos/${GITHUB_REPO}/releases/latest" + gh_url="${GITHUB_API_URL%/}/repos/${GITHUB_REPO}/releases/latest" + json="${TMP}/latest.json" + download_with_fallback "$gh_url" "$S3_LATEST_URL" "$json" api fi - json="${TMP}/latest.json" - download_or_fail "$url" "$json" api VERSION=$(first_tag_name_from_json "$json") VERSION=${VERSION#v} @@ -1227,6 +1358,7 @@ main() { print_banner normalize_version + validate_install_source if [ -z "$VERSION" ]; then say_step "Resolving latest version..." resolve_version @@ -1238,7 +1370,9 @@ main() { FILENAME="agora-cli_v${VERSION}_${OS}_${ARCH}.${ARCHIVE_EXT}" ARCHIVE_URL="${RELEASES_DOWNLOAD_BASE_URL%/}/v${VERSION}/${FILENAME}" + ARCHIVE_URL_S3="${S3_DOWNLOAD_BASE_URL%/}/v${VERSION}/${FILENAME}" CHECKSUMS_URL="${RELEASES_DOWNLOAD_BASE_URL%/}/v${VERSION}/checksums.txt" + CHECKSUMS_URL_S3="${S3_DOWNLOAD_BASE_URL%/}/v${VERSION}/checksums.txt" ARCHIVE_PATH="${TMP}/${FILENAME}" CHECKSUMS_PATH="${TMP}/checksums.txt" EXTRACTED_BINARY="${TMP}/${BINARY_NAME}" @@ -1263,8 +1397,11 @@ main() { if [ "$DRY_RUN" = "1" ]; then say_step "Dry run - no changes will be made." - say " archive: ${ARCHIVE_URL}" - say " checksums: ${CHECKSUMS_URL}" + say " archive: ${ARCHIVE_URL}" + say " archive (s3): ${ARCHIVE_URL_S3}" + say " checksums: ${CHECKSUMS_URL}" + say " checksums(s3):${CHECKSUMS_URL_S3}" + say " source mode: ${AGORA_INSTALL_SOURCE}" say " install: ${DESTINATION}" sudo_status="no" if [ "$USE_SUDO" = "1" ]; then @@ -1295,10 +1432,10 @@ main() { say_step "Installing agora ${VERSION} (${OS}/${ARCH}) -> ${DESTINATION}" say_step "Downloading archive..." - download_or_fail "$ARCHIVE_URL" "$ARCHIVE_PATH" archive + download_with_fallback "$ARCHIVE_URL" "$ARCHIVE_URL_S3" "$ARCHIVE_PATH" archive say_step "Verifying checksum..." - download_or_fail "$CHECKSUMS_URL" "$CHECKSUMS_PATH" + download_with_fallback "$CHECKSUMS_URL" "$CHECKSUMS_URL_S3" "$CHECKSUMS_PATH" EXPECTED_SHA=$( awk -v file="$FILENAME" ' diff --git a/scripts/test-installer-fallback.sh b/scripts/test-installer-fallback.sh new file mode 100755 index 0000000..27d5897 --- /dev/null +++ b/scripts/test-installer-fallback.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env sh +# Unit tests for install.sh source-selection / fallback logic. +# Extracts download_with_fallback (and set_fetch_profile) from install.sh and +# runs them with a stubbed single-attempt fetch so no network is touched. +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +INSTALLER="$ROOT/install.sh" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP" 2>/dev/null || true' EXIT HUP INT TERM +ASSERTIONS=0 + +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } + +extract() { + awk ' + /^set_fetch_profile\(\) \{/,/^\}/ { print } + /^download_with_fallback\(\) \{/,/^\}/ { print } + ' "$INSTALLER" +} + +# run -> prints "ATTEMPTS:" and "RESULT:" +run() { + source_mode=$1 + gh_status=$2 + s3_status=$3 + out_file="$TMP/out.txt" + : >"$out_file" + ( + set +e + AGORA_INSTALL_SOURCE=$source_mode + CURL_PROTO_OPTS="" ; CURL_RETRY_OPTS="" ; CURL_FASTFAIL_OPTS="" + WGET_RETRY_OPTS="" ; WGET_FASTFAIL_OPTS="" + curl_common_opts="" ; wget_common_opts="" + RELEASES_PAGE_URL="https://example/releases" + EXIT_NETWORK=20 + ATTEMPTS="" + GH_STATUS=$gh_status + S3_STATUS=$s3_status + verbose() { :; } + say() { :; } + warn() { :; } + err() { :; } + die() { printf 'ATTEMPTS:%s\n' "$ATTEMPTS"; printf 'RESULT:die\n'; exit 1; } + # Stub the single-attempt primitive used by download_with_fallback. + _fetch_once() { + __url=$1 + case "$__url" in + *dl.agora.io*|*mirror*|*s3*) ATTEMPTS="$ATTEMPTS s3"; return "$S3_STATUS" ;; + *) ATTEMPTS="$ATTEMPTS github"; return "$GH_STATUS" ;; + esac + } + eval "$(extract)" + download_with_fallback \ + "https://api.github.com/x" \ + "https://dl.agora.io/cli/latest.json" \ + "$TMP/dl.out" api + rc=$? + printf 'ATTEMPTS:%s\n' "$ATTEMPTS" + printf 'RESULT:%s\n' "$rc" + ) >"$out_file" 2>/dev/null || true + cat "$out_file" +} + +assert_eq() { + got=$1; want=$2; msg=$3 + if [ "$got" != "$want" ]; then + fail "$msg (got '$got', want '$want')" + fi + ASSERTIONS=$((ASSERTIONS + 1)) +} + +# auto + GitHub succeeds: only GitHub attempted. +out=$(run auto 0 0) +assert_eq "$(printf '%s' "$out" | grep '^ATTEMPTS:')" "ATTEMPTS: github" "auto/gh-ok attempts" +assert_eq "$(printf '%s' "$out" | grep '^RESULT:')" "RESULT:0" "auto/gh-ok result" + +# auto + GitHub fails + S3 succeeds: GitHub then S3, success. +out=$(run auto 1 0) +assert_eq "$(printf '%s' "$out" | grep '^ATTEMPTS:')" "ATTEMPTS: github s3" "auto/gh-fail attempts" +assert_eq "$(printf '%s' "$out" | grep '^RESULT:')" "RESULT:0" "auto/gh-fail result" + +# auto + both fail: GitHub then S3, then die. +out=$(run auto 1 1) +assert_eq "$(printf '%s' "$out" | grep '^ATTEMPTS:')" "ATTEMPTS: github s3" "auto/both-fail attempts" +assert_eq "$(printf '%s' "$out" | grep '^RESULT:')" "RESULT:die" "auto/both-fail result" + +# github-only: never touches S3 even when GitHub fails. +out=$(run github 1 0) +assert_eq "$(printf '%s' "$out" | grep '^ATTEMPTS:')" "ATTEMPTS: github" "github-only attempts" +assert_eq "$(printf '%s' "$out" | grep '^RESULT:')" "RESULT:die" "github-only result" + +# s3-only: skips GitHub entirely. +out=$(run s3 0 0) +assert_eq "$(printf '%s' "$out" | grep '^ATTEMPTS:')" "ATTEMPTS: s3" "s3-only attempts" +assert_eq "$(printf '%s' "$out" | grep '^RESULT:')" "RESULT:0" "s3-only result" + +printf 'ok - %d assertions passed\n' "$ASSERTIONS"