Skip to content

Commit 07c89ff

Browse files
fix(install): authenticate api.github.com call to avoid 60/hr rate limit (#1157)
* fix(install): authenticate api.github.com call to avoid 60/hr rate limit All three installers called api.github.com/repos/.../releases/latest unauthenticated to resolve the latest release tag. GitHub caps unauthenticated REST API requests at 60/hour per source IP (shared across NAT/CGNAT/corporate proxy users), causing opaque 'Failed to fetch latest version' errors. Each installer now detects a token (precedence: GITHUB_TOKEN env > GH_TOKEN env > 'gh auth token') and attaches it as an Authorization: Bearer header to ONLY the version-resolution API call. Falls back to anonymous (unchanged behavior) when no token is available. Release downloads and git clone are left unauthenticated - they are not subject to the 60/hr REST limit. Token variables are cleared from memory immediately after the API call (defense in depth). install.ps1 wraps the API call in try/catch to surface a friendlier error instead of a raw terminating exception under $ErrorActionPreference=Stop. Docs updated with a rate-limited-IP install section and a troubleshooting entry. Parity + negative-assertion tests added. Closes #1156 * fix(install): retry anonymously when authenticated api call fails Addresses review feedback on #1157: a stale/revoked token (expired GITHUB_TOKEN lingering in CI images, dotfiles, direnv) gets a 401 and would break an install that works fine anonymously today. Each installer now retries the api.github.com version-resolution call without the auth header when the authenticated attempt fails: - install.sh: `|| true` + `[ -z "$latest_tag" ]` gate - install.ps1: try/catch, retries with no -Headers on catch - install.cmd: ERRORLEVEL check, retries without !GH_AUTH_HEADER! A bad token costs one extra request; nobody is worse off than today. New test asserts the retry path exists in all three installers (89 pass). Also corrects the AI disclosure: the assistant was opencode, not Claude. * fix(install): address second-review findings Errorlevel check adjacency in install.cmd, delayed-expansion token reads, token charset guard, PATH-resolved gh with --hostname github.com, 401-only anonymous retry in install.sh and install.ps1, preserved original errors in install.ps1, honest cleanup comments, non-vacuous test assertions, and doc corrections (1000/hr Actions token, zero-scope guidance, -Version for PowerShell). --------- Co-authored-by: tbontb-iaq <tbontb-iaq@users.noreply.github.com> Co-authored-by: Michael Ramos <mdramos8@gmail.com>
1 parent b5cf065 commit 07c89ff

6 files changed

Lines changed: 337 additions & 3 deletions

File tree

apps/marketing/src/content/docs/getting-started/installation.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,22 @@ For `curl … | bash` pipelines you can set `PLANNOTATOR_MINIMAL=1` in the envir
8383

8484
</details>
8585

86+
<details>
87+
<summary><strong>Installing behind a rate-limited IP</strong></summary>
88+
89+
The installer queries the GitHub API (`api.github.com`) to resolve the latest release tag. Unauthenticated API requests are capped at **60 per hour per source IP**, so installs can fail on shared egress IPs (corporate proxies, NAT/CGNAT, CI runners) or when retrying/debugging within the same hour, with an opaque `Failed to fetch latest version` error.
90+
91+
If you hit this, export a token before running the installer - it reads `GITHUB_TOKEN`, then `GH_TOKEN`, and falls back to `gh auth token` (github.com credentials only) when the `gh` CLI is authenticated. A personal access token raises the limit to 5,000/hour; the built-in `GITHUB_TOKEN` in GitHub Actions gets 1,000/hour per repository. The repository is public, so a token with no scopes is sufficient - prefer a fine-grained or zero-scope token over a broad classic PAT:
92+
93+
```bash
94+
export GITHUB_TOKEN=ghp_xxx
95+
curl -fsSL https://plannotator.ai/install.sh | bash
96+
```
97+
98+
Or authenticate once with `gh auth login` - no env var needed, the installer picks the token up automatically. Only the version-resolution API call is authenticated; release downloads and `git clone` are unaffected. See [Troubleshooting](/docs/guides/troubleshooting/) for details.
99+
100+
</details>
101+
86102
Every release includes SHA256 checksums (verified automatically) and optional [SLSA build provenance](/docs/reference/verifying-your-install/) attestations.
87103

88104
## Claude Code

apps/marketing/src/content/docs/guides/troubleshooting.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,23 @@ sidebar:
66
section: "Guides"
77
---
88

9+
## Installer fails with "Failed to fetch latest version"
10+
11+
The installer queries the GitHub API (`api.github.com`) to resolve the latest release tag. Unauthenticated API requests are capped at **60 per hour per source IP** - not per user - so the install fails (with `Failed to fetch latest version` on macOS/Linux/WSL, `Failed to get latest version` on Windows) on shared egress IPs (corporate proxies, NAT/CGNAT, CI runners) or when retrying within the same hour.
12+
13+
Provide a token and the installer attaches it to that API call automatically. A personal access token raises the limit to 5,000/hour; the built-in `GITHUB_TOKEN` in GitHub Actions gets 1,000/hour per repository. The repository is public, so a token with no scopes is sufficient - prefer a fine-grained or zero-scope token over a broad classic PAT. The installer reads, in order:
14+
15+
1. `GITHUB_TOKEN` env var
16+
2. `GH_TOKEN` env var
17+
3. `gh auth token` for github.com (when the `gh` CLI is installed and authenticated; a GitHub Enterprise default host is never used for this call)
18+
19+
```bash
20+
export GITHUB_TOKEN=ghp_xxx
21+
curl -fsSL https://plannotator.ai/install.sh | bash
22+
```
23+
24+
Or run `gh auth login` once - no env var needed. To pin a specific version instead (which skips the API call entirely), pass `--version vX.Y.Z` on macOS/Linux (`curl -fsSL https://plannotator.ai/install.sh | bash -s -- --version vX.Y.Z`) or `-Version vX.Y.Z` with the PowerShell installer. Only the version-resolution call is authenticated; release downloads and `git clone` are unaffected. See [issue #1156](https://github.com/backnotprop/plannotator/issues/1156).
25+
926
## Lost a Plannotator tab?
1027

1128
If you accidentally close a Plannotator browser tab, the server is still running in the background. You can find and reopen it:

scripts/install.cmd

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,15 +204,87 @@ REM Get version to install
204204
if /i "!VERSION!"=="latest" (
205205
echo Fetching latest version...
206206

207+
REM api.github.com caps unauthenticated requests at 60/hour per source IP,
208+
REM which fails installs behind shared egress IPs (NAT/CGNAT/corporate
209+
REM proxies) and during repeated/debug runs within an hour. Attach an
210+
REM Authorization header when a token is available (raises the limit to
211+
REM 5000/hour); when none is found, fall back to anonymous (unchanged
212+
REM behavior). Precedence matches `gh`: GITHUB_TOKEN > GH_TOKEN > gh auth token.
213+
REM Read the env vars via delayed expansion, never percent expansion, so
214+
REM the value is not re-parsed by cmd's phase-1 expansion - a value
215+
REM containing metacharacters or exclamation marks can neither inject
216+
REM commands nor be corrupted here.
217+
set "GH_TOKEN_VAL="
218+
if defined GITHUB_TOKEN set "GH_TOKEN_VAL=!GITHUB_TOKEN!"
219+
if not defined GH_TOKEN_VAL if defined GH_TOKEN set "GH_TOKEN_VAL=!GH_TOKEN!"
220+
if not defined GH_TOKEN_VAL (
221+
REM Resolve gh via `where` and invoke the absolute path so the for /f
222+
REM command line never runs a bare `gh` name, which cmd would resolve
223+
REM from the current directory first. (`where` itself also searches
224+
REM the CWD first - accepted limitation, documented here.)
225+
REM --hostname github.com scopes the fallback to github.com
226+
REM credentials, so a gh setup whose default host is a GitHub
227+
REM Enterprise server never leaks a GHES token to api.github.com. On
228+
REM an ancient gh without the flag, stderr is swallowed and we fall
229+
REM back to anonymous.
230+
set "GH_EXE="
231+
for /f "delims=" %%g in ('where gh 2^>nul') do if not defined GH_EXE set "GH_EXE=%%g"
232+
if defined GH_EXE (
233+
for /f "delims=" %%i in ('"!GH_EXE!" auth token --hostname github.com 2^>nul') do set "GH_TOKEN_VAL=%%i"
234+
)
235+
set "GH_EXE="
236+
)
237+
REM Charset allowlist: GitHub tokens are [A-Za-z0-9_] (plus - to be
238+
REM safe). Strip every allowed character (cmd substitution is
239+
REM case-insensitive, covering A-Z too); anything left over means an
240+
REM unexpected character - a quote in particular could break out of the
241+
REM quoted Authorization header on the curl line below - so drop the
242+
REM token and go anonymous. Done with pure delayed-expansion
243+
REM substitutions: a findstr pipe cannot be used because delayed
244+
REM expansion does not survive into pipe children, and writing the token
245+
REM to a temp file for findstr would leak the secret to disk.
246+
if defined GH_TOKEN_VAL (
247+
set "TOKEN_RESIDUE=!GH_TOKEN_VAL!"
248+
for %%c in (a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9 _ -) do if defined TOKEN_RESIDUE set "TOKEN_RESIDUE=!TOKEN_RESIDUE:%%c=!"
249+
if defined TOKEN_RESIDUE set "GH_TOKEN_VAL="
250+
set "TOKEN_RESIDUE="
251+
)
252+
if defined GH_TOKEN_VAL (
253+
set "GH_AUTH_HEADER=-H "Authorization: Bearer !GH_TOKEN_VAL!""
254+
) else (
255+
set "GH_AUTH_HEADER="
256+
)
257+
207258
REM Download release info to a randomized temp file so concurrent
208259
REM invocations don't collide and a same-user pre-placed symlink at
209260
REM a predictable path can't redirect curl's output.
210261
set "RELEASE_JSON=%TEMP%\plannotator-release-%RANDOM%.json"
211-
curl -fsSL "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!"
262+
curl -fsSL !GH_AUTH_HEADER! "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!"
263+
REM A stale/revoked token (expired GITHUB_TOKEN lingering in CI images,
264+
REM dotfiles, direnv) gets a 401 here and would break an install that
265+
REM works fine anonymously today. If the authenticated call failed and
266+
REM we had a token, retry once without the header so a bad token costs
267+
REM one extra request but never blocks an otherwise-working install.
268+
REM Note: install.sh / install.ps1 inspect the HTTP status and retry
269+
REM only on 401; capturing the status portably in batch is not worth the
270+
REM complexity, so cmd retries on any failure when a token was used - an
271+
REM accepted cmd-only compromise. See backnotprop/plannotator#1157.
272+
REM Both ERRORLEVEL reads below sit immediately adjacent to the curl
273+
REM they test (only REM lines and a no-op if in between); the token
274+
REM clears deliberately come AFTER the failure check because `set` can
275+
REM disturb ERRORLEVEL.
276+
if !ERRORLEVEL! neq 0 if defined GH_AUTH_HEADER (
277+
curl -fsSL "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!"
278+
)
212279
if !ERRORLEVEL! neq 0 (
213280
echo Failed to get latest version >&2
214281
exit /b 1
215282
)
283+
REM Drop the local token copies; downloads and git clone are anonymous.
284+
REM GITHUB_TOKEN / GH_TOKEN themselves remain in the environment exactly
285+
REM as the user set them.
286+
set "GH_TOKEN_VAL="
287+
set "GH_AUTH_HEADER="
216288

217289
REM Extract tag_name from JSON
218290
for /f "tokens=2 delims=:," %%i in ('findstr /c:"\"tag_name\"" "!RELEASE_JSON!"') do (

scripts/install.ps1

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,53 @@ foreach ($oldPath in $oldLocations) {
9999

100100
if ($Version -eq "latest") {
101101
Write-Host "Fetching latest version..."
102-
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest"
102+
103+
# api.github.com caps unauthenticated requests at 60/hour per source IP,
104+
# which fails installs behind shared egress IPs (NAT/CGNAT/corporate
105+
# proxies) and during repeated/debug runs within an hour. Attach an
106+
# Authorization header when a token is available (raises the limit to
107+
# 5000/hour); when none is found, fall back to anonymous (unchanged
108+
# behavior). Precedence matches `gh`: GITHUB_TOKEN > GH_TOKEN > gh auth token.
109+
$ghToken = $env:GITHUB_TOKEN
110+
if (-not $ghToken) { $ghToken = $env:GH_TOKEN }
111+
if (-not $ghToken -and (Get-Command gh -ErrorAction SilentlyContinue)) {
112+
# --hostname github.com scopes the fallback to github.com credentials,
113+
# so a gh setup whose default host is a GitHub Enterprise server never
114+
# leaks a GHES token to api.github.com. On an ancient gh without the
115+
# flag, stderr is swallowed and we fall back to anonymous.
116+
try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { }
117+
}
118+
$ghHeaders = if ($ghToken) { @{ Authorization = "Bearer $ghToken" } } else { @{} }
119+
# A stale/revoked token (expired GITHUB_TOKEN lingering in CI images,
120+
# dotfiles, direnv) gets a 401 here and would break an install that
121+
# works fine anonymously today. Retry anonymously ONLY on HTTP 401:
122+
# requests carrying invalid credentials count against the anonymous
123+
# 60/hour per-IP pool, so a blind retry on any failure would double the
124+
# burn, and network failures gain nothing from a second attempt. The
125+
# [int] cast handles both Windows PowerShell 5.1 (HttpWebResponse enum)
126+
# and PowerShell 7 (HttpResponseMessage); the inner try guards a null
127+
# Response (e.g. DNS failure). See backnotprop/plannotator#1157.
128+
$apiUrl = "https://api.github.com/repos/$repo/releases/latest"
129+
try {
130+
$release = Invoke-RestMethod -Uri $apiUrl -Headers $ghHeaders
131+
} catch {
132+
$status = $null
133+
try { $status = [int]$_.Exception.Response.StatusCode } catch { }
134+
if ($ghHeaders.Count -gt 0 -and $status -eq 401) {
135+
try {
136+
$release = Invoke-RestMethod -Uri $apiUrl
137+
} catch {
138+
Write-Error "Failed to fetch latest version: $($_.Exception.Message)"
139+
exit 1
140+
}
141+
} else {
142+
Write-Error "Failed to fetch latest version: $($_.Exception.Message) (if this is HTTP 403, the GitHub API may be rate-limiting your IP; see https://github.com/backnotprop/plannotator/issues/1156)"
143+
exit 1
144+
}
145+
}
146+
# Drop the local token copies; GITHUB_TOKEN / GH_TOKEN themselves remain
147+
# in the environment exactly as the user set them.
148+
$ghToken = $null; $ghHeaders = $null; $apiUrl = $null
103149
$latestTag = $release.tag_name
104150

105151
if (-not $latestTag) {

scripts/install.sh

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,48 @@ fi
287287

288288
if [ "$VERSION" = "latest" ]; then
289289
echo "Fetching latest version..."
290-
latest_tag=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4)
290+
291+
# api.github.com caps unauthenticated requests at 60/hour per source IP,
292+
# which fails installs behind shared egress IPs (NAT/CGNAT/corporate
293+
# proxies) and during repeated/debug runs within an hour. Attach an
294+
# Authorization header when a token is available (raises the limit to
295+
# 5000/hour); when none is found, fall back to anonymous (unchanged
296+
# behavior). Precedence matches `gh`: GITHUB_TOKEN > GH_TOKEN > gh auth token.
297+
GH_AUTH_HEADER=()
298+
if [ -n "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then
299+
GH_AUTH_HEADER=(-H "Authorization: Bearer ${GITHUB_TOKEN:-${GH_TOKEN}}")
300+
elif command -v gh >/dev/null 2>&1; then
301+
# --hostname github.com scopes the fallback to github.com credentials,
302+
# so a gh setup whose default host is a GitHub Enterprise server never
303+
# leaks a GHES token to api.github.com. On an ancient gh without the
304+
# flag, stderr is swallowed and we fall back to anonymous.
305+
if _gh_token="$(gh auth token --hostname github.com 2>/dev/null)" && [ -n "$_gh_token" ]; then
306+
GH_AUTH_HEADER=(-H "Authorization: Bearer ${_gh_token}")
307+
fi
308+
fi
309+
# A stale/revoked token (expired GITHUB_TOKEN lingering in CI images,
310+
# dotfiles, direnv) gets a 401 here and would break an install that
311+
# works fine anonymously today. Retry anonymously ONLY on HTTP 401:
312+
# requests carrying invalid credentials count against the anonymous
313+
# 60/hour per-IP pool, so a blind retry on any failure would double the
314+
# burn, and network failures gain nothing from a second attempt.
315+
# Note: no -f here, so a 401 body doesn't abort curl before -w prints
316+
# the status code. See backnotprop/plannotator#1157.
317+
_api_url="https://api.github.com/repos/${REPO}/releases/latest"
318+
_api_body=$(curl -sSL -w '\n%{http_code}' "${GH_AUTH_HEADER[@]}" "$_api_url" 2>/dev/null) || true
319+
_api_code="${_api_body##*$'\n'}"
320+
if [ "$_api_code" = "401" ] && [ ${#GH_AUTH_HEADER[@]} -gt 0 ]; then
321+
_api_body=$(curl -sSL -w '\n%{http_code}' "$_api_url" 2>/dev/null) || true
322+
_api_code="${_api_body##*$'\n'}"
323+
fi
324+
if [ "$_api_code" = "200" ]; then
325+
latest_tag=$(printf '%s' "$_api_body" | grep '"tag_name"' | cut -d'"' -f4)
326+
else
327+
latest_tag=""
328+
fi
329+
# Drop the local token copies; GITHUB_TOKEN / GH_TOKEN themselves remain
330+
# in the environment exactly as the user set them.
331+
unset _gh_token GH_AUTH_HEADER _api_url _api_body _api_code
291332

292333
if [ -z "$latest_tag" ]; then
293334
echo "Failed to fetch latest version" >&2

0 commit comments

Comments
 (0)