From a7c6579aefc38737bc5ba0eb9fdcbca6ead92158 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:22:01 +0000 Subject: [PATCH 1/7] fix(installer): find the latest version without the API rate limit Unpinned installs failed with "Could not determine CLI version" in Upsun builds, very likely because unauthenticated GitHub API requests are rate limited per IP address, and build hosts share their IPs. The error was hidden, so the log did not show the cause. - Read a GitHub token from COMPOSER_AUTH and ~/.global/auth.json (the token Upsun provides in project containers), as the legacy PHP installer did, as well as from GITHUB_TOKEN and the gh CLI. - Check the token against /rate_limit, which does not count against the limit, and say when a token is ignored. - If the API request fails, log the error and fall back to the redirect of https://github.com/upsun/cli/releases/latest, which is not subject to the API rate limit. Refs #73 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnUBGurqFyfoRBS7aXYqDR --- installer.sh | 83 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 69 insertions(+), 14 deletions(-) diff --git a/installer.sh b/installer.sh index 883be486a..eacf7554e 100644 --- a/installer.sh +++ b/installer.sh @@ -244,21 +244,73 @@ check_curl() { fi } +# Print the github.com token from Composer auth JSON (read from stdin), if any. +# See https://getcomposer.org/doc/articles/authentication-for-private-packages.md#github-oauth +github_token_from_composer_auth() { + tr -d '\r\n' | sed -n 's/.*"github-oauth"[[:space:]]*:[[:space:]]*{[^}]*"github\.com"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 +} + +# Use a GitHub token, if one is available, for higher API rate limits. +# Unauthenticated requests are limited per IP address, which build +# environments often share. setup_github_token() { - if gh auth status >/dev/null 2>&1; then - GITHUB_TOKEN="$(gh auth token)" - if ! github_curl https://api.github.com/repos/upsun/cli/releases/latest >/dev/null 2>&1; then - GITHUB_TOKEN="" - else - output " [*] Using GitHub auth from the gh CLI" "success" - fi - elif [ ! -z "${GITHUB_TOKEN}" ]; then - if ! github_curl https://api.github.com/repos/upsun/cli/releases/latest >/dev/null 2>&1; then - GITHUB_TOKEN="" - else - output " [*] Using GitHub auth from the GITHUB_TOKEN env variable" "success" - fi + token_source="" + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)" + token_source="the gh CLI" + elif [ -n "${GITHUB_TOKEN}" ]; then + token_source="the GITHUB_TOKEN env variable" + elif [ -n "${COMPOSER_AUTH:-}" ]; then + GITHUB_TOKEN="$(printf '%s' "${COMPOSER_AUTH}" | github_token_from_composer_auth)" + token_source="the COMPOSER_AUTH env variable" + fi + # The token that Upsun provides in project containers. It allows for higher + # rate limits but is otherwise unprivileged. + if [ -z "${GITHUB_TOKEN}" ] && [ -n "${HOME:-}" ] && [ -r "${HOME}/.global/auth.json" ]; then + GITHUB_TOKEN="$(github_token_from_composer_auth < "${HOME}/.global/auth.json")" + token_source="${HOME}/.global/auth.json" fi + if [ -z "${GITHUB_TOKEN}" ]; then + return + fi + + # Check the token (requests to this endpoint do not count against the rate limit). + if github_curl https://api.github.com/rate_limit >/dev/null 2>&1; then + output " [*] Using GitHub auth from ${token_source}" "success" + else + output " [ ] Ignoring the GitHub token from ${token_source}, as GitHub did not accept it" "warning" + GITHUB_TOKEN="" + fi +} + +# Print the latest release tag, from the GitHub API. +latest_tag_from_api() { + api_error_file=$(mktemp) + tag=$(github_curl https://api.github.com/repos/upsun/cli/releases/latest 2>"$api_error_file" \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + if [ -z "$tag" ]; then + api_error=$(head -n 1 "$api_error_file") + output " [ ] Could not get the latest release from the GitHub API: ${api_error}" "warning" >&2 + case "$api_error" in + *" 403"*|*" 429"*) + if [ -z "${GITHUB_TOKEN}" ]; then + output " Unauthenticated requests are rate limited: set GITHUB_TOKEN to use a token" "comment" >&2 + fi + ;; + esac + fi + rm -f "$api_error_file" + printf '%s' "$tag" +} + +# Print the latest release tag, from the redirect of the latest release page. +# This is not subject to the API's rate limits. +latest_tag_from_redirect() { + location=$(curl -fsS -o /dev/null -w '%{redirect_url}' https://github.com/upsun/cli/releases/latest 2>&1 || true) + case "$location" in + */releases/tag/*) printf '%s' "${location##*/releases/tag/}" ;; + *) output " [ ] Could not get the latest release from github.com: ${location:-no redirect}" "warning" >&2 ;; + esac } # Check that Gzip is installed @@ -283,7 +335,10 @@ check_ca_certificates() { check_version() { if [ -z "${VERSION}" ]; then - tag=$(github_curl https://api.github.com/repos/upsun/cli/releases/latest 2>/dev/null | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + tag=$(latest_tag_from_api) + if [ -z "$tag" ]; then + tag=$(latest_tag_from_redirect) + fi else tag=${VERSION} fi From 7cc34f712442c4d2f5ed4e785441d580796b9ffe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:35:49 +0000 Subject: [PATCH 2/7] test(installer): test the version lookup against a fake GitHub Make the GitHub URLs overridable (RELEASES_URL, RELEASES_API_URL), and add integration tests that run installer.sh against an httptest server: the token sources and their precedence, invalid tokens, the rate-limit fallback to the latest release redirect, and failures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnUBGurqFyfoRBS7aXYqDR --- CLAUDE.md | 2 + installer.sh | 12 +- integration-tests/installer_test.go | 309 ++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 integration-tests/installer_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 849d1cb5e..c81839010 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,6 +165,8 @@ The mock API (`pkg/mockapi`) validates each successful request and response agai `make test-integration-php-coverage` runs them for PHP line coverage of the legacy CLI: a CLI built with `legacy.PHPPath` set to `legacy/scripts/coverage/php` runs the legacy source (`TEST_CLI_PHAR_PATH`) on the system PHP, which must load PCOV. Each PHP process writes its coverage to `PHP_COVERAGE_DIR`, and `legacy/scripts/coverage/report.php` prints a summary by directory and writes an HTML report to `dist/php-coverage/html`. CI's `integration-test-php-coverage` job writes the summary to the job summary, and uploads the HTML report. +`installer_test.go` runs `installer.sh` (raw install method) against a fake GitHub server, using `RELEASES_URL` and `RELEASES_API_URL`, with an environment built from scratch so that real tokens are not used. The `test-installer` workflow and `scripts/test/installer*.sh` test the other install methods with real packages. + CI also runs the integration tests on macOS and Windows, using CLIs cross-compiled on Linux (`build-cross`). Skip tests that cannot work on a platform with `t.Skip` and a reason. The legacy CLI writes CRLF line endings on Windows: the `cmdFactory` run helpers convert them to LF. ### Configuration diff --git a/installer.sh b/installer.sh index eacf7554e..74dc677db 100644 --- a/installer.sh +++ b/installer.sh @@ -15,7 +15,11 @@ vendor_name="Upsun" # Define this to force install method : "${INSTALL_METHOD:=}" -: "${URL:=https://github.com/upsun/cli/releases/download}" +# The GitHub releases page, and the releases API endpoint (overridden in tests). +: "${RELEASES_URL:=https://github.com/upsun/cli/releases}" +: "${RELEASES_API_URL:=https://api.github.com/repos/upsun/cli/releases}" + +: "${URL:=${RELEASES_URL}/download}" # Force Upsun CLI installation in this directory instead of system directory : "${INSTALL_DIR:=}" @@ -275,7 +279,7 @@ setup_github_token() { fi # Check the token (requests to this endpoint do not count against the rate limit). - if github_curl https://api.github.com/rate_limit >/dev/null 2>&1; then + if github_curl "${RELEASES_API_URL%%/repos/*}/rate_limit" >/dev/null 2>&1; then output " [*] Using GitHub auth from ${token_source}" "success" else output " [ ] Ignoring the GitHub token from ${token_source}, as GitHub did not accept it" "warning" @@ -286,7 +290,7 @@ setup_github_token() { # Print the latest release tag, from the GitHub API. latest_tag_from_api() { api_error_file=$(mktemp) - tag=$(github_curl https://api.github.com/repos/upsun/cli/releases/latest 2>"$api_error_file" \ + tag=$(github_curl "${RELEASES_API_URL}/latest" 2>"$api_error_file" \ | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) if [ -z "$tag" ]; then api_error=$(head -n 1 "$api_error_file") @@ -306,7 +310,7 @@ latest_tag_from_api() { # Print the latest release tag, from the redirect of the latest release page. # This is not subject to the API's rate limits. latest_tag_from_redirect() { - location=$(curl -fsS -o /dev/null -w '%{redirect_url}' https://github.com/upsun/cli/releases/latest 2>&1 || true) + location=$(curl -fsS -o /dev/null -w '%{redirect_url}' "${RELEASES_URL}/latest" 2>&1 || true) case "$location" in */releases/tag/*) printf '%s' "${location##*/releases/tag/}" ;; *) output " [ ] Could not get the latest release from github.com: ${location:-no redirect}" "warning" >&2 ;; diff --git a/integration-tests/installer_test.go b/integration-tests/installer_test.go new file mode 100644 index 000000000..f5fb914b0 --- /dev/null +++ b/integration-tests/installer_test.go @@ -0,0 +1,309 @@ +package tests + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeGitHub serves the GitHub endpoints that installer.sh uses: the releases API, +// the latest release redirect, and release downloads. +type fakeGitHub struct { + t *testing.T + srv *httptest.Server + + tag string // The latest release tag. + validToken string // The token that the API accepts. + apiStatus int // If set, the status of the latest release API response. + noRedirect bool // If set, the latest release page does not redirect. + + mu sync.Mutex + requests []fakeGitHubRequest +} + +type fakeGitHubRequest struct { + path, auth string +} + +func newFakeGitHub(t *testing.T) *fakeGitHub { + g := &fakeGitHub{t: t, tag: "v1.2.3", validToken: "valid-token"} + mux := http.NewServeMux() + mux.HandleFunc("GET /api/rate_limit", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+g.validToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"resources":{}}`)) + }) + mux.HandleFunc("GET /api/repos/upsun/cli/releases/latest", func(w http.ResponseWriter, _ *http.Request) { + if g.apiStatus != 0 { + w.WriteHeader(g.apiStatus) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"tag_name": g.tag, "name": g.tag}) + }) + mux.HandleFunc("GET /upsun/cli/releases/latest", func(w http.ResponseWriter, r *http.Request) { + if g.noRedirect { + w.WriteHeader(http.StatusNotFound) + return + } + http.Redirect(w, r, g.srv.URL+"/upsun/cli/releases/tag/"+g.tag, http.StatusFound) + }) + mux.HandleFunc("GET /upsun/cli/releases/download/{tag}/{file}", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(fakeCLIArchive(t, strings.TrimPrefix(r.PathValue("tag"), "v"))) + }) + g.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + g.mu.Lock() + g.requests = append(g.requests, fakeGitHubRequest{path: r.URL.Path, auth: r.Header.Get("Authorization")}) + g.mu.Unlock() + mux.ServeHTTP(w, r) + })) + t.Cleanup(g.srv.Close) + return g +} + +// requestsTo returns the requests made to a path. +func (g *fakeGitHub) requestsTo(path string) []fakeGitHubRequest { + g.mu.Lock() + defer g.mu.Unlock() + var found []fakeGitHubRequest + for _, r := range g.requests { + if r.path == path { + found = append(found, r) + } + } + return found +} + +// fakeCLIArchive returns a release archive containing an "upsun" script that prints its version. +func fakeCLIArchive(t *testing.T, version string) []byte { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + script := "#!/bin/sh\necho \"fake upsun " + version + "\"\n" + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "upsun", Mode: 0o755, Size: int64(len(script))})) + _, err := tw.Write([]byte(script)) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + return buf.Bytes() +} + +type installerResult struct { + output string + err error + binPath string +} + +// runInstaller runs installer.sh with the raw install method, against the fake GitHub. +// The environment is built from scratch, so that the developer's or CI's tokens are not used. +// A fake gh CLI is first in the PATH: it is logged in if env has GH_FAKE_TOKEN. +func runInstaller(t *testing.T, g *fakeGitHub, env []string, homeFiles map[string]string) installerResult { + dir := t.TempDir() + home := filepath.Join(dir, "home") + binDir := filepath.Join(dir, "bin") + fakeBin := filepath.Join(dir, "fake-bin") + require.NoError(t, os.MkdirAll(home, 0o700)) + require.NoError(t, os.MkdirAll(fakeBin, 0o700)) + for name, content := range homeFiles { + p := filepath.Join(home, name) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o700)) + require.NoError(t, os.WriteFile(p, []byte(content), 0o600)) + } + gh := `#!/bin/sh +[ -n "${GH_FAKE_TOKEN:-}" ] || exit 1 +case "$1 $2" in + "auth status") exit 0 ;; + "auth token") echo "$GH_FAKE_TOKEN" ;; + *) exit 1 ;; +esac +` + require.NoError(t, os.WriteFile(filepath.Join(fakeBin, "gh"), []byte(gh), 0o755)) + + installer, err := filepath.Abs("../installer.sh") + require.NoError(t, err) + cmd := exec.Command("sh", installer) + cmd.Dir = dir + cmd.Env = append([]string{ + "PATH=" + fakeBin + string(os.PathListSeparator) + os.Getenv("PATH"), + "HOME=" + home, + "TMPDIR=" + dir, + "CI=1", // Skips shell completion. + "INSTALL_METHOD=raw", + "INSTALL_DIR=" + binDir, + "INSTALL_LOG=" + filepath.Join(dir, "install.log"), + "RELEASES_URL=" + g.srv.URL + "/upsun/cli/releases", + "RELEASES_API_URL=" + g.srv.URL + "/api/repos/upsun/cli/releases", + }, env...) + out, err := cmd.CombinedOutput() + t.Log(string(out)) + return installerResult{output: string(out), err: err, binPath: filepath.Join(binDir, "upsun")} +} + +// assertInstalled checks that the installed binary has the version. +func assertInstalled(t *testing.T, r installerResult, version string) { + require.NoError(t, r.err) + out, err := exec.Command(r.binPath).Output() + require.NoError(t, err) + assert.Equal(t, "fake upsun "+version+"\n", string(out)) +} + +func TestInstallerLatestVersion(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test due to -short flag") + } + if runtime.GOOS == "windows" { + t.Skip("installer.sh does not run on Windows") + } + t.Parallel() + + const latestPath = "/api/repos/upsun/cli/releases/latest" + composerAuth := `{ + "github-oauth": { + "github.example.com": "other-host", + "github.com": "valid-token" + } +}` + + cases := []struct { + name string + env []string + homeFiles map[string]string + apiStatus int + noRedirect bool + + wantVersion string // Empty if the install fails. + wantAuth string // The Authorization header sent to the releases API. + wantOutput []string + dontOutput []string + }{ + { + name: "no token", + wantVersion: "1.2.3", + wantOutput: []string{"No version specified, using latest (1.2.3)"}, + dontOutput: []string{"Using GitHub auth"}, + }, + { + name: "pinned version", + env: []string{"VERSION=1.0.0"}, + wantVersion: "1.0.0", + wantOutput: []string{"Version 1.0.0 specified"}, + }, + { + name: "GITHUB_TOKEN", + env: []string{"GITHUB_TOKEN=valid-token", "COMPOSER_AUTH={}"}, + wantVersion: "1.2.3", + wantAuth: "Bearer valid-token", + wantOutput: []string{"Using GitHub auth from the GITHUB_TOKEN env variable"}, + }, + { + name: "gh CLI", + env: []string{"GH_FAKE_TOKEN=valid-token"}, + wantVersion: "1.2.3", + wantAuth: "Bearer valid-token", + wantOutput: []string{"Using GitHub auth from the gh CLI"}, + }, + { + name: "COMPOSER_AUTH", + env: []string{"COMPOSER_AUTH=" + composerAuth}, + wantVersion: "1.2.3", + wantAuth: "Bearer valid-token", + wantOutput: []string{"Using GitHub auth from the COMPOSER_AUTH env variable"}, + }, + { + name: "project container auth.json", + homeFiles: map[string]string{".global/auth.json": composerAuth}, + wantVersion: "1.2.3", + wantAuth: "Bearer valid-token", + wantOutput: []string{"Using GitHub auth from ", ".global/auth.json"}, + }, + { + name: "COMPOSER_AUTH without a github.com token", + env: []string{"COMPOSER_AUTH={\"gitlab-oauth\":{\"gitlab.com\":\"x\"}}"}, + homeFiles: map[string]string{".global/auth.json": composerAuth}, + wantVersion: "1.2.3", + wantAuth: "Bearer valid-token", + wantOutput: []string{".global/auth.json"}, + }, + { + name: "invalid token", + env: []string{"GITHUB_TOKEN=invalid-token"}, + wantVersion: "1.2.3", + wantOutput: []string{"Ignoring the GitHub token from the GITHUB_TOKEN env variable"}, + dontOutput: []string{"Using GitHub auth"}, + }, + { + name: "rate limited", + apiStatus: http.StatusForbidden, + wantVersion: "1.2.3", + wantOutput: []string{ + "Could not get the latest release from the GitHub API", + "error: 403", + "set GITHUB_TOKEN to use a token", + "No version specified, using latest (1.2.3)", + }, + }, + { + name: "API error", + apiStatus: http.StatusInternalServerError, + wantVersion: "1.2.3", + wantOutput: []string{"error: 500"}, + dontOutput: []string{"set GITHUB_TOKEN"}, + }, + { + name: "no version found", + apiStatus: http.StatusForbidden, + noRedirect: true, + wantOutput: []string{ + "error: 403", + "Could not get the latest release from github.com", + "ERROR: Could not determine CLI version", + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + g := newFakeGitHub(t) + g.apiStatus = c.apiStatus + g.noRedirect = c.noRedirect + + r := runInstaller(t, g, c.env, c.homeFiles) + if c.wantVersion == "" { + assert.Error(t, r.err) + assert.NoFileExists(t, r.binPath) + } else { + assertInstalled(t, r, c.wantVersion) + } + for _, s := range c.wantOutput { + assert.Contains(t, r.output, s) + } + for _, s := range c.dontOutput { + assert.NotContains(t, r.output, s) + } + + latest := g.requestsTo(latestPath) + if slices.ContainsFunc(c.env, func(e string) bool { return strings.HasPrefix(e, "VERSION=") }) { + assert.Empty(t, latest, "a pinned version should not need the API") + return + } + require.Len(t, latest, 1) + assert.Equal(t, c.wantAuth, latest[0].auth) + }) + } +} From 1b66cc3a389d9fcabb5709cdb13c4e52f7634adf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:42:22 +0000 Subject: [PATCH 3/7] fix(installer): try each token source until GitHub accepts one A token that GitHub rejected stopped the search, so a valid token from a later source (e.g. ~/.global/auth.json) was not used, and a logged-in gh CLI overrode GITHUB_TOKEN. Now each source is tried in order, with GITHUB_TOKEN first (as with gh itself). Only a 401 counts as a rejection: if the check fails for another reason, the token is kept, and the error is logged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnUBGurqFyfoRBS7aXYqDR --- installer.sh | 68 +++++++++++++++-------- integration-tests/installer_test.go | 83 ++++++++++++++++++++++++----- 2 files changed, 114 insertions(+), 37 deletions(-) diff --git a/installer.sh b/installer.sh index 74dc677db..0d2ff70af 100644 --- a/installer.sh +++ b/installer.sh @@ -254,36 +254,58 @@ github_token_from_composer_auth() { tr -d '\r\n' | sed -n 's/.*"github-oauth"[[:space:]]*:[[:space:]]*{[^}]*"github\.com"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 } +# Try a GitHub token: use it unless GitHub rejects it. +# Arguments: token, source (for messages) +# Returns 0 if the token is used. +try_github_token() { + if [ -z "$1" ]; then + return 1 + fi + # Requests to this endpoint do not count against the rate limit. + check_error_file=$(mktemp) + status=$(curl -sS -o /dev/null -w '%{http_code}' -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $1" "${RELEASES_API_URL%%/repos/*}/rate_limit" 2>"$check_error_file" || true) + check_error=$(head -n 1 "$check_error_file") + rm -f "$check_error_file" + case "$status" in + 2*) + output " [*] Using GitHub auth from $2" "success" + ;; + 401) + output " [ ] Ignoring the GitHub token from $2, as GitHub rejected it" "warning" + return 1 + ;; + *) + # A network or server error says nothing about the token, so it is kept. + output " [ ] Could not check the GitHub token from $2 (${check_error:-HTTP $status}): using it anyway" "warning" + ;; + esac + GITHUB_TOKEN="$1" +} + # Use a GitHub token, if one is available, for higher API rate limits. # Unauthenticated requests are limited per IP address, which build -# environments often share. +# environments often share. Sources are tried in order, until GitHub +# accepts a token. setup_github_token() { - token_source="" - if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then - GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)" - token_source="the gh CLI" - elif [ -n "${GITHUB_TOKEN}" ]; then - token_source="the GITHUB_TOKEN env variable" - elif [ -n "${COMPOSER_AUTH:-}" ]; then - GITHUB_TOKEN="$(printf '%s' "${COMPOSER_AUTH}" | github_token_from_composer_auth)" - token_source="the COMPOSER_AUTH env variable" + env_token="${GITHUB_TOKEN}" + GITHUB_TOKEN="" + # The env variable comes first, as with the gh CLI itself. + if try_github_token "${env_token}" "the GITHUB_TOKEN env variable"; then + return fi - # The token that Upsun provides in project containers. It allows for higher - # rate limits but is otherwise unprivileged. - if [ -z "${GITHUB_TOKEN}" ] && [ -n "${HOME:-}" ] && [ -r "${HOME}/.global/auth.json" ]; then - GITHUB_TOKEN="$(github_token_from_composer_auth < "${HOME}/.global/auth.json")" - token_source="${HOME}/.global/auth.json" + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 \ + && try_github_token "$(gh auth token 2>/dev/null || true)" "the gh CLI"; then + return fi - if [ -z "${GITHUB_TOKEN}" ]; then + if [ -n "${COMPOSER_AUTH:-}" ] \ + && try_github_token "$(printf '%s' "${COMPOSER_AUTH}" | github_token_from_composer_auth)" "the COMPOSER_AUTH env variable"; then return fi - - # Check the token (requests to this endpoint do not count against the rate limit). - if github_curl "${RELEASES_API_URL%%/repos/*}/rate_limit" >/dev/null 2>&1; then - output " [*] Using GitHub auth from ${token_source}" "success" - else - output " [ ] Ignoring the GitHub token from ${token_source}, as GitHub did not accept it" "warning" - GITHUB_TOKEN="" + # The token that Upsun provides in project containers. It allows for higher + # rate limits but is otherwise unprivileged. + if [ -n "${HOME:-}" ] && [ -r "${HOME}/.global/auth.json" ]; then + try_github_token "$(github_token_from_composer_auth < "${HOME}/.global/auth.json")" "${HOME}/.global/auth.json" || true fi } diff --git a/integration-tests/installer_test.go b/integration-tests/installer_test.go index f5fb914b0..7eb90a919 100644 --- a/integration-tests/installer_test.go +++ b/integration-tests/installer_test.go @@ -26,10 +26,11 @@ type fakeGitHub struct { t *testing.T srv *httptest.Server - tag string // The latest release tag. - validToken string // The token that the API accepts. - apiStatus int // If set, the status of the latest release API response. - noRedirect bool // If set, the latest release page does not redirect. + tag string // The latest release tag. + validTokens []string // The tokens that the API accepts. + rateLimitStatus int // If set, the status of the rate limit API response. + apiStatus int // If set, the status of the latest release API response. + noRedirect bool // If set, the latest release page does not redirect. mu sync.Mutex requests []fakeGitHubRequest @@ -40,10 +41,14 @@ type fakeGitHubRequest struct { } func newFakeGitHub(t *testing.T) *fakeGitHub { - g := &fakeGitHub{t: t, tag: "v1.2.3", validToken: "valid-token"} + g := &fakeGitHub{t: t, tag: "v1.2.3", validTokens: []string{"valid-token", "gh-token"}} mux := http.NewServeMux() mux.HandleFunc("GET /api/rate_limit", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer "+g.validToken { + if g.rateLimitStatus != 0 { + w.WriteHeader(g.rateLimitStatus) + return + } + if !slices.Contains(g.validTokens, strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) { w.WriteHeader(http.StatusUnauthorized) return } @@ -180,11 +185,12 @@ func TestInstallerLatestVersion(t *testing.T) { }` cases := []struct { - name string - env []string - homeFiles map[string]string - apiStatus int - noRedirect bool + name string + env []string + homeFiles map[string]string + rateLimitStatus int + apiStatus int + noRedirect bool wantVersion string // Empty if the install fails. wantAuth string // The Authorization header sent to the releases API. @@ -212,11 +218,59 @@ func TestInstallerLatestVersion(t *testing.T) { }, { name: "gh CLI", - env: []string{"GH_FAKE_TOKEN=valid-token"}, + env: []string{"GH_FAKE_TOKEN=gh-token"}, wantVersion: "1.2.3", - wantAuth: "Bearer valid-token", + wantAuth: "Bearer gh-token", wantOutput: []string{"Using GitHub auth from the gh CLI"}, }, + { + name: "GITHUB_TOKEN before the gh CLI", + env: []string{"GITHUB_TOKEN=valid-token", "GH_FAKE_TOKEN=gh-token"}, + wantVersion: "1.2.3", + wantAuth: "Bearer valid-token", + dontOutput: []string{"gh CLI"}, + }, + { + name: "rejected GITHUB_TOKEN falls through to the gh CLI", + env: []string{"GITHUB_TOKEN=invalid-token", "GH_FAKE_TOKEN=gh-token"}, + wantVersion: "1.2.3", + wantAuth: "Bearer gh-token", + wantOutput: []string{ + "Ignoring the GitHub token from the GITHUB_TOKEN env variable, as GitHub rejected it", + "Using GitHub auth from the gh CLI", + }, + }, + { + name: "rejected gh CLI token falls through to COMPOSER_AUTH", + env: []string{"GH_FAKE_TOKEN=invalid-token", "COMPOSER_AUTH=" + composerAuth}, + wantVersion: "1.2.3", + wantAuth: "Bearer valid-token", + wantOutput: []string{ + "Ignoring the GitHub token from the gh CLI", + "Using GitHub auth from the COMPOSER_AUTH env variable", + }, + }, + { + name: "rejected COMPOSER_AUTH token falls through to auth.json", + env: []string{`COMPOSER_AUTH={"github-oauth":{"github.com":"invalid-token"}}`}, + homeFiles: map[string]string{".global/auth.json": composerAuth}, + wantVersion: "1.2.3", + wantAuth: "Bearer valid-token", + wantOutput: []string{ + "Ignoring the GitHub token from the COMPOSER_AUTH env variable", + "Using GitHub auth from ", + }, + }, + { + name: "token check error", + env: []string{"GITHUB_TOKEN=valid-token"}, + rateLimitStatus: http.StatusBadGateway, + wantVersion: "1.2.3", + wantAuth: "Bearer valid-token", + wantOutput: []string{ + "Could not check the GitHub token from the GITHUB_TOKEN env variable (HTTP 502): using it anyway", + }, + }, { name: "COMPOSER_AUTH", env: []string{"COMPOSER_AUTH=" + composerAuth}, @@ -243,7 +297,7 @@ func TestInstallerLatestVersion(t *testing.T) { name: "invalid token", env: []string{"GITHUB_TOKEN=invalid-token"}, wantVersion: "1.2.3", - wantOutput: []string{"Ignoring the GitHub token from the GITHUB_TOKEN env variable"}, + wantOutput: []string{"Ignoring the GitHub token from the GITHUB_TOKEN env variable, as GitHub rejected it"}, dontOutput: []string{"Using GitHub auth"}, }, { @@ -280,6 +334,7 @@ func TestInstallerLatestVersion(t *testing.T) { t.Run(c.name, func(t *testing.T) { t.Parallel() g := newFakeGitHub(t) + g.rateLimitStatus = c.rateLimitStatus g.apiStatus = c.apiStatus g.noRedirect = c.noRedirect From 561fd1dcdc1ce119ead94f45013080e9a2e0040b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:16:39 +0000 Subject: [PATCH 4/7] refactor(installer): read Composer auth JSON with jq Upsun containers, where ~/.global/auth.json is expected, have jq. Without jq, the Composer auth sources give no token, and the other sources are still tried. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnUBGurqFyfoRBS7aXYqDR --- installer.sh | 3 +- integration-tests/installer_test.go | 54 +++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/installer.sh b/installer.sh index 0d2ff70af..fd2844848 100644 --- a/installer.sh +++ b/installer.sh @@ -250,8 +250,9 @@ check_curl() { # Print the github.com token from Composer auth JSON (read from stdin), if any. # See https://getcomposer.org/doc/articles/authentication-for-private-packages.md#github-oauth +# This needs jq, which Upsun containers have: without it, no token is found. github_token_from_composer_auth() { - tr -d '\r\n' | sed -n 's/.*"github-oauth"[[:space:]]*:[[:space:]]*{[^}]*"github\.com"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 + jq -r '."github-oauth"."github.com" | strings' 2>/dev/null || true } # Try a GitHub token: use it unless GitHub rejects it. diff --git a/integration-tests/installer_test.go b/integration-tests/installer_test.go index 7eb90a919..7f660c5e5 100644 --- a/integration-tests/installer_test.go +++ b/integration-tests/installer_test.go @@ -117,7 +117,8 @@ type installerResult struct { // runInstaller runs installer.sh with the raw install method, against the fake GitHub. // The environment is built from scratch, so that the developer's or CI's tokens are not used. // A fake gh CLI is first in the PATH: it is logged in if env has GH_FAKE_TOKEN. -func runInstaller(t *testing.T, g *fakeGitHub, env []string, homeFiles map[string]string) installerResult { +// If noJQ is set, jq is left out of the PATH. +func runInstaller(t *testing.T, g *fakeGitHub, env []string, homeFiles map[string]string, noJQ bool) installerResult { dir := t.TempDir() home := filepath.Join(dir, "home") binDir := filepath.Join(dir, "bin") @@ -139,12 +140,17 @@ esac ` require.NoError(t, os.WriteFile(filepath.Join(fakeBin, "gh"), []byte(gh), 0o755)) + path := fakeBin + string(os.PathListSeparator) + os.Getenv("PATH") + if noJQ { + path = fakeBin + string(os.PathListSeparator) + pathWithout(t, filepath.Join(dir, "path-bin"), "jq") + } + installer, err := filepath.Abs("../installer.sh") require.NoError(t, err) cmd := exec.Command("sh", installer) cmd.Dir = dir cmd.Env = append([]string{ - "PATH=" + fakeBin + string(os.PathListSeparator) + os.Getenv("PATH"), + "PATH=" + path, "HOME=" + home, "TMPDIR=" + dir, "CI=1", // Skips shell completion. @@ -159,6 +165,28 @@ esac return installerResult{output: string(out), err: err, binPath: filepath.Join(binDir, "upsun")} } +// pathWithout creates a directory of links to the executables in the PATH, leaving out one name, and returns it. +func pathWithout(t *testing.T, dir, exclude string) string { + require.NoError(t, os.MkdirAll(dir, 0o700)) + for _, pathDir := range filepath.SplitList(os.Getenv("PATH")) { + entries, err := os.ReadDir(pathDir) + if err != nil { + continue + } + for _, e := range entries { + link := filepath.Join(dir, e.Name()) + if e.Name() == exclude || e.IsDir() { + continue + } + if _, err := os.Lstat(link); err == nil { + continue // An earlier PATH entry wins. + } + require.NoError(t, os.Symlink(filepath.Join(pathDir, e.Name()), link)) + } + } + return dir +} + // assertInstalled checks that the installed binary has the version. func assertInstalled(t *testing.T, r installerResult, version string) { require.NoError(t, r.err) @@ -191,6 +219,8 @@ func TestInstallerLatestVersion(t *testing.T) { rateLimitStatus int apiStatus int noRedirect bool + needsJQ bool // The case reads Composer auth JSON. + noJQ bool // Leave jq out of the PATH. wantVersion string // Empty if the install fails. wantAuth string // The Authorization header sent to the releases API. @@ -242,6 +272,7 @@ func TestInstallerLatestVersion(t *testing.T) { }, { name: "rejected gh CLI token falls through to COMPOSER_AUTH", + needsJQ: true, env: []string{"GH_FAKE_TOKEN=invalid-token", "COMPOSER_AUTH=" + composerAuth}, wantVersion: "1.2.3", wantAuth: "Bearer valid-token", @@ -252,6 +283,7 @@ func TestInstallerLatestVersion(t *testing.T) { }, { name: "rejected COMPOSER_AUTH token falls through to auth.json", + needsJQ: true, env: []string{`COMPOSER_AUTH={"github-oauth":{"github.com":"invalid-token"}}`}, homeFiles: map[string]string{".global/auth.json": composerAuth}, wantVersion: "1.2.3", @@ -273,6 +305,7 @@ func TestInstallerLatestVersion(t *testing.T) { }, { name: "COMPOSER_AUTH", + needsJQ: true, env: []string{"COMPOSER_AUTH=" + composerAuth}, wantVersion: "1.2.3", wantAuth: "Bearer valid-token", @@ -280,6 +313,7 @@ func TestInstallerLatestVersion(t *testing.T) { }, { name: "project container auth.json", + needsJQ: true, homeFiles: map[string]string{".global/auth.json": composerAuth}, wantVersion: "1.2.3", wantAuth: "Bearer valid-token", @@ -287,12 +321,21 @@ func TestInstallerLatestVersion(t *testing.T) { }, { name: "COMPOSER_AUTH without a github.com token", + needsJQ: true, env: []string{"COMPOSER_AUTH={\"gitlab-oauth\":{\"gitlab.com\":\"x\"}}"}, homeFiles: map[string]string{".global/auth.json": composerAuth}, wantVersion: "1.2.3", wantAuth: "Bearer valid-token", wantOutput: []string{".global/auth.json"}, }, + { + name: "Composer auth JSON is ignored without jq", + env: []string{"COMPOSER_AUTH=" + composerAuth}, + homeFiles: map[string]string{".global/auth.json": composerAuth}, + noJQ: true, + wantVersion: "1.2.3", + dontOutput: []string{"GitHub auth", "GitHub token"}, + }, { name: "invalid token", env: []string{"GITHUB_TOKEN=invalid-token"}, @@ -333,12 +376,17 @@ func TestInstallerLatestVersion(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() + if c.needsJQ { + if _, err := exec.LookPath("jq"); err != nil { + t.Skip("jq is not installed") + } + } g := newFakeGitHub(t) g.rateLimitStatus = c.rateLimitStatus g.apiStatus = c.apiStatus g.noRedirect = c.noRedirect - r := runInstaller(t, g, c.env, c.homeFiles) + r := runInstaller(t, g, c.env, c.homeFiles, c.noJQ) if c.wantVersion == "" { assert.Error(t, r.err) assert.NoFileExists(t, r.binPath) From 67d29a3e0b843f3624fae0a0a95fe099f5e9b159 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:18:12 +0000 Subject: [PATCH 5/7] fix(installer): say when Composer auth cannot be read without jq Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnUBGurqFyfoRBS7aXYqDR --- installer.sh | 13 ++++++++++--- integration-tests/installer_test.go | 5 +++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/installer.sh b/installer.sh index fd2844848..ce40dc821 100644 --- a/installer.sh +++ b/installer.sh @@ -250,7 +250,7 @@ check_curl() { # Print the github.com token from Composer auth JSON (read from stdin), if any. # See https://getcomposer.org/doc/articles/authentication-for-private-packages.md#github-oauth -# This needs jq, which Upsun containers have: without it, no token is found. +# This needs jq, which Upsun containers have. github_token_from_composer_auth() { jq -r '."github-oauth"."github.com" | strings' 2>/dev/null || true } @@ -299,14 +299,21 @@ setup_github_token() { && try_github_token "$(gh auth token 2>/dev/null || true)" "the gh CLI"; then return fi + auth_file="${HOME:-}/.global/auth.json" + if ! command -v jq >/dev/null 2>&1; then + if [ -n "${COMPOSER_AUTH:-}" ] || { [ -n "${HOME:-}" ] && [ -r "${auth_file}" ]; }; then + output " [ ] Composer auth was found, but jq is not installed to read a GitHub token from it" "warning" + fi + return + fi if [ -n "${COMPOSER_AUTH:-}" ] \ && try_github_token "$(printf '%s' "${COMPOSER_AUTH}" | github_token_from_composer_auth)" "the COMPOSER_AUTH env variable"; then return fi # The token that Upsun provides in project containers. It allows for higher # rate limits but is otherwise unprivileged. - if [ -n "${HOME:-}" ] && [ -r "${HOME}/.global/auth.json" ]; then - try_github_token "$(github_token_from_composer_auth < "${HOME}/.global/auth.json")" "${HOME}/.global/auth.json" || true + if [ -n "${HOME:-}" ] && [ -r "${auth_file}" ]; then + try_github_token "$(github_token_from_composer_auth < "${auth_file}")" "${auth_file}" || true fi } diff --git a/integration-tests/installer_test.go b/integration-tests/installer_test.go index 7f660c5e5..0fb6f5647 100644 --- a/integration-tests/installer_test.go +++ b/integration-tests/installer_test.go @@ -329,12 +329,13 @@ func TestInstallerLatestVersion(t *testing.T) { wantOutput: []string{".global/auth.json"}, }, { - name: "Composer auth JSON is ignored without jq", + name: "Composer auth JSON is skipped without jq", env: []string{"COMPOSER_AUTH=" + composerAuth}, homeFiles: map[string]string{".global/auth.json": composerAuth}, noJQ: true, wantVersion: "1.2.3", - dontOutput: []string{"GitHub auth", "GitHub token"}, + wantOutput: []string{"Composer auth was found, but jq is not installed to read a GitHub token from it"}, + dontOutput: []string{"Using GitHub auth"}, }, { name: "invalid token", From 456b04cd2263a2526b3ad33258cf45624dde956e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 21:02:53 +0000 Subject: [PATCH 6/7] refactor(installer): get the latest version from the redirect first The latest release page's redirect needs no token and is not subject to the API's rate limit, so it is now tried first: a normal install makes one request to find the version, and reads no tokens. If it fails, the API is tried with each token source in order, in the request itself (a 401 means the next source is tried, so the separate /rate_limit check is gone), and then without a token. An error with a token is not retried. A token is sent with the download only if GitHub accepted it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnUBGurqFyfoRBS7aXYqDR --- installer.sh | 172 +++++++------ integration-tests/installer_test.go | 366 ++++++++++++++++------------ 2 files changed, 313 insertions(+), 225 deletions(-) diff --git a/installer.sh b/installer.sh index ce40dc821..436db26ef 100644 --- a/installer.sh +++ b/installer.sh @@ -31,7 +31,7 @@ vendor_name="Upsun" : "${BREW_TAP:=upsun/tap}" : "${BREW_FORMULA:=upsun/tap/upsun-cli}" -# GitHub token check +# A GitHub token, for the API (only used if the latest release page does not redirect) : "${GITHUB_TOKEN:=}" # CI specifics @@ -40,6 +40,9 @@ vendor_name="Upsun" : "${RUN_ID:=}" # global variables +env_github_token="${GITHUB_TOKEN}" +# A token that GitHub accepted, used for downloads too. +GITHUB_TOKEN="" binary="upsun" # How to run the binary: the full path if its directory is not in $PATH. run_cmd="$binary" @@ -255,96 +258,120 @@ github_token_from_composer_auth() { jq -r '."github-oauth"."github.com" | strings' 2>/dev/null || true } -# Try a GitHub token: use it unless GitHub rejects it. +# Get the latest release tag from the redirect of the latest release page, +# into $tag. This is not subject to the API's rate limits, and needs no token. +latest_tag_from_redirect() { + location=$(curl -fsS -o /dev/null -w '%{redirect_url}' "${RELEASES_URL}/latest" 2>&1 || true) + case "$location" in + */releases/tag/*) + tag="${location##*/releases/tag/}" + ;; + *) + output " [ ] Could not get the latest release from github.com: ${location:-no redirect}" "warning" + return 1 + ;; + esac +} + +# Get the latest release tag from the GitHub API, into $tag. +# Arguments: [token] +# Returns 0 on success, 1 if GitHub rejected the token, or 2 on another error, +# described by $api_error. +latest_tag_from_api() { + body_file=$(mktemp) + error_file=$(mktemp) + if [ -n "${1:-}" ]; then + status=$(curl -sS -o "$body_file" -w '%{http_code}' -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $1" "${RELEASES_API_URL}/latest" 2>"$error_file" || true) + else + status=$(curl -sS -o "$body_file" -w '%{http_code}' -H "Accept: application/vnd.github+json" \ + "${RELEASES_API_URL}/latest" 2>"$error_file" || true) + fi + api_error=$(head -n 1 "$error_file") + tag="" + case "$status" in + 2*) tag=$(sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$body_file" | head -n 1) ;; + esac + rm -f "$body_file" "$error_file" + if [ -n "$tag" ]; then + return 0 + fi + if [ "$status" = "401" ]; then + return 1 + fi + api_error="${api_error:-HTTP ${status}}" + return 2 +} + +# Get the latest release tag from the GitHub API with a token, into $tag. # Arguments: token, source (for messages) -# Returns 0 if the token is used. -try_github_token() { +# Returns 0 on success, 1 if there is no token or GitHub rejected it, or 2 on another error. +latest_tag_with_token() { if [ -z "$1" ]; then return 1 fi - # Requests to this endpoint do not count against the rate limit. - check_error_file=$(mktemp) - status=$(curl -sS -o /dev/null -w '%{http_code}' -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer $1" "${RELEASES_API_URL%%/repos/*}/rate_limit" 2>"$check_error_file" || true) - check_error=$(head -n 1 "$check_error_file") - rm -f "$check_error_file" - case "$status" in - 2*) + token_rc=0 + latest_tag_from_api "$1" || token_rc=$? + case "$token_rc" in + 0) output " [*] Using GitHub auth from $2" "success" + # The token is also used to download the release. + GITHUB_TOKEN="$1" ;; - 401) + 1) output " [ ] Ignoring the GitHub token from $2, as GitHub rejected it" "warning" - return 1 ;; *) - # A network or server error says nothing about the token, so it is kept. - output " [ ] Could not check the GitHub token from $2 (${check_error:-HTTP $status}): using it anyway" "warning" + output " [ ] Could not get the latest release from the GitHub API: ${api_error}" "warning" ;; esac - GITHUB_TOKEN="$1" + return "$token_rc" } -# Use a GitHub token, if one is available, for higher API rate limits. -# Unauthenticated requests are limited per IP address, which build -# environments often share. Sources are tried in order, until GitHub -# accepts a token. -setup_github_token() { - env_token="${GITHUB_TOKEN}" - GITHUB_TOKEN="" +# Get the latest release tag from the GitHub API, into $tag, using the first +# token that GitHub accepts, or none. Unauthenticated API requests are limited +# per IP address, which build environments often share. +latest_tag_from_api_with_auth() { + rc=0 # The env variable comes first, as with the gh CLI itself. - if try_github_token "${env_token}" "the GITHUB_TOKEN env variable"; then - return - fi - if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 \ - && try_github_token "$(gh auth token 2>/dev/null || true)" "the gh CLI"; then - return + latest_tag_with_token "${env_github_token}" "the GITHUB_TOKEN env variable" || rc=$? + if [ "$rc" = 1 ] && command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + rc=0 + latest_tag_with_token "$(gh auth token 2>/dev/null || true)" "the gh CLI" || rc=$? fi auth_file="${HOME:-}/.global/auth.json" - if ! command -v jq >/dev/null 2>&1; then + if [ "$rc" = 1 ] && ! command -v jq >/dev/null 2>&1; then if [ -n "${COMPOSER_AUTH:-}" ] || { [ -n "${HOME:-}" ] && [ -r "${auth_file}" ]; }; then output " [ ] Composer auth was found, but jq is not installed to read a GitHub token from it" "warning" fi - return - fi - if [ -n "${COMPOSER_AUTH:-}" ] \ - && try_github_token "$(printf '%s' "${COMPOSER_AUTH}" | github_token_from_composer_auth)" "the COMPOSER_AUTH env variable"; then - return - fi - # The token that Upsun provides in project containers. It allows for higher - # rate limits but is otherwise unprivileged. - if [ -n "${HOME:-}" ] && [ -r "${auth_file}" ]; then - try_github_token "$(github_token_from_composer_auth < "${auth_file}")" "${auth_file}" || true + elif [ "$rc" = 1 ]; then + if [ -n "${COMPOSER_AUTH:-}" ]; then + rc=0 + latest_tag_with_token "$(printf '%s' "${COMPOSER_AUTH}" | github_token_from_composer_auth)" \ + "the COMPOSER_AUTH env variable" || rc=$? + fi + # The token that Upsun provides in project containers. It allows for + # higher rate limits but is otherwise unprivileged. + if [ "$rc" = 1 ] && [ -n "${HOME:-}" ] && [ -r "${auth_file}" ]; then + rc=0 + latest_tag_with_token "$(github_token_from_composer_auth < "${auth_file}")" "${auth_file}" || rc=$? + fi fi -} + case "$rc" in + 0) return 0 ;; + 2) return 1 ;; # Another token would not help. + esac -# Print the latest release tag, from the GitHub API. -latest_tag_from_api() { - api_error_file=$(mktemp) - tag=$(github_curl "${RELEASES_API_URL}/latest" 2>"$api_error_file" \ - | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) - if [ -z "$tag" ]; then - api_error=$(head -n 1 "$api_error_file") - output " [ ] Could not get the latest release from the GitHub API: ${api_error}" "warning" >&2 - case "$api_error" in - *" 403"*|*" 429"*) - if [ -z "${GITHUB_TOKEN}" ]; then - output " Unauthenticated requests are rate limited: set GITHUB_TOKEN to use a token" "comment" >&2 - fi - ;; - esac + if latest_tag_from_api; then + return 0 fi - rm -f "$api_error_file" - printf '%s' "$tag" -} - -# Print the latest release tag, from the redirect of the latest release page. -# This is not subject to the API's rate limits. -latest_tag_from_redirect() { - location=$(curl -fsS -o /dev/null -w '%{redirect_url}' "${RELEASES_URL}/latest" 2>&1 || true) - case "$location" in - */releases/tag/*) printf '%s' "${location##*/releases/tag/}" ;; - *) output " [ ] Could not get the latest release from github.com: ${location:-no redirect}" "warning" >&2 ;; + output " [ ] Could not get the latest release from the GitHub API: ${api_error}" "warning" + case "$api_error" in + *"HTTP 403"*|*"HTTP 429"*) + output " Unauthenticated requests are rate limited: set GITHUB_TOKEN to use a token" "comment" + ;; esac + return 1 } # Check that Gzip is installed @@ -369,10 +396,8 @@ check_ca_certificates() { check_version() { if [ -z "${VERSION}" ]; then - tag=$(latest_tag_from_api) - if [ -z "$tag" ]; then - tag=$(latest_tag_from_redirect) - fi + tag="" + latest_tag_from_redirect || latest_tag_from_api_with_auth || true else tag=${VERSION} fi @@ -910,9 +935,6 @@ check_architecture check_install_method if [ "raw" = "${INSTALL_METHOD}" ]; then check_curl - if [ -z "${VERSION}" ]; then - setup_github_token - fi check_version check_gzip check_shasum diff --git a/integration-tests/installer_test.go b/integration-tests/installer_test.go index 0fb6f5647..751f80afe 100644 --- a/integration-tests/installer_test.go +++ b/integration-tests/installer_test.go @@ -4,7 +4,7 @@ import ( "archive/tar" "bytes" "compress/gzip" - "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -20,17 +20,16 @@ import ( "github.com/stretchr/testify/require" ) -// fakeGitHub serves the GitHub endpoints that installer.sh uses: the releases API, -// the latest release redirect, and release downloads. +// fakeGitHub serves the GitHub endpoints that installer.sh uses: the latest release page (which redirects to +// the release), the releases API, and release downloads. type fakeGitHub struct { t *testing.T srv *httptest.Server - tag string // The latest release tag. - validTokens []string // The tokens that the API accepts. - rateLimitStatus int // If set, the status of the rate limit API response. - apiStatus int // If set, the status of the latest release API response. - noRedirect bool // If set, the latest release page does not redirect. + tag string // The latest release tag. + validTokens []string // The tokens that the API accepts. + redirectStatus int // If set, the status of the latest release page, instead of a redirect. + apiStatus int // If set, the status of the latest release API response (for an accepted or no token). mu sync.Mutex requests []fakeGitHubRequest @@ -43,30 +42,26 @@ type fakeGitHubRequest struct { func newFakeGitHub(t *testing.T) *fakeGitHub { g := &fakeGitHub{t: t, tag: "v1.2.3", validTokens: []string{"valid-token", "gh-token"}} mux := http.NewServeMux() - mux.HandleFunc("GET /api/rate_limit", func(w http.ResponseWriter, r *http.Request) { - if g.rateLimitStatus != 0 { - w.WriteHeader(g.rateLimitStatus) + mux.HandleFunc("GET /upsun/cli/releases/latest", func(w http.ResponseWriter, r *http.Request) { + if g.redirectStatus != 0 { + w.WriteHeader(g.redirectStatus) return } - if !slices.Contains(g.validTokens, strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) { + http.Redirect(w, r, g.srv.URL+"/upsun/cli/releases/tag/"+g.tag, http.StatusFound) + }) + mux.HandleFunc("GET /api/repos/upsun/cli/releases/latest", func(w http.ResponseWriter, r *http.Request) { + if auth := r.Header.Get("Authorization"); auth != "" && + !slices.Contains(g.validTokens, strings.TrimPrefix(auth, "Bearer ")) { w.WriteHeader(http.StatusUnauthorized) return } - _, _ = w.Write([]byte(`{"resources":{}}`)) - }) - mux.HandleFunc("GET /api/repos/upsun/cli/releases/latest", func(w http.ResponseWriter, _ *http.Request) { if g.apiStatus != 0 { w.WriteHeader(g.apiStatus) return } - _ = json.NewEncoder(w).Encode(map[string]any{"tag_name": g.tag, "name": g.tag}) - }) - mux.HandleFunc("GET /upsun/cli/releases/latest", func(w http.ResponseWriter, r *http.Request) { - if g.noRedirect { - w.WriteHeader(http.StatusNotFound) - return - } - http.Redirect(w, r, g.srv.URL+"/upsun/cli/releases/tag/"+g.tag, http.StatusFound) + // The API's JSON is pretty-printed. + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, "{\n \"name\": %q,\n \"tag_name\": %q\n}\n", g.tag, g.tag) }) mux.HandleFunc("GET /upsun/cli/releases/download/{tag}/{file}", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(fakeCLIArchive(t, strings.TrimPrefix(r.PathValue("tag"), "v"))) @@ -81,14 +76,14 @@ func newFakeGitHub(t *testing.T) *fakeGitHub { return g } -// requestsTo returns the requests made to a path. -func (g *fakeGitHub) requestsTo(path string) []fakeGitHubRequest { +// authHeaders returns the Authorization headers of the requests to paths with a prefix, in order. +func (g *fakeGitHub) authHeaders(pathPrefix string) []string { g.mu.Lock() defer g.mu.Unlock() - var found []fakeGitHubRequest + found := []string{} for _, r := range g.requests { - if r.path == path { - found = append(found, r) + if strings.HasPrefix(r.path, pathPrefix) { + found = append(found, r.auth) } } return found @@ -204,173 +199,245 @@ func TestInstallerLatestVersion(t *testing.T) { } t.Parallel() - const latestPath = "/api/repos/upsun/cli/releases/latest" + const ( + redirectPath = "/upsun/cli/releases/latest" + apiPath = "/api/repos/upsun/cli/releases/latest" + downloadPath = "/upsun/cli/releases/download/" + ) composerAuth := `{ "github-oauth": { "github.example.com": "other-host", "github.com": "valid-token" } }` + noRedirect := "Could not get the latest release from github.com: no redirect" cases := []struct { - name string - env []string - homeFiles map[string]string - rateLimitStatus int - apiStatus int - noRedirect bool - needsJQ bool // The case reads Composer auth JSON. - noJQ bool // Leave jq out of the PATH. + name string + env []string + homeFiles map[string]string + redirectStatus int + apiStatus int + needsJQ bool // The case reads Composer auth JSON. + noJQ bool // Leave jq out of the PATH. - wantVersion string // Empty if the install fails. - wantAuth string // The Authorization header sent to the releases API. - wantOutput []string - dontOutput []string + wantVersion string // Empty if the install fails. + wantRedirects int // The number of requests to the latest release page. + wantAPIAuth []string // The Authorization headers sent to the releases API, in order. + wantDownloadAuth string // The Authorization header sent with the download. + wantOutput []string + dontOutput []string }{ - { - name: "no token", - wantVersion: "1.2.3", - wantOutput: []string{"No version specified, using latest (1.2.3)"}, - dontOutput: []string{"Using GitHub auth"}, - }, { name: "pinned version", - env: []string{"VERSION=1.0.0"}, + env: []string{"VERSION=1.0.0", "GITHUB_TOKEN=valid-token"}, wantVersion: "1.0.0", + wantAPIAuth: []string{}, wantOutput: []string{"Version 1.0.0 specified"}, + dontOutput: []string{"GitHub auth"}, + }, + { + name: "redirect", + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{}, + wantOutput: []string{"No version specified, using latest (1.2.3)"}, + dontOutput: []string{"Could not", "GitHub auth"}, + }, + { + name: "redirect without using tokens", + env: []string{"GITHUB_TOKEN=valid-token", "GH_FAKE_TOKEN=gh-token"}, + homeFiles: map[string]string{".global/auth.json": composerAuth}, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{}, + dontOutput: []string{"GitHub auth", "GitHub token"}, + }, + { + name: "API without a token", + redirectStatus: http.StatusNotFound, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{""}, + wantOutput: []string{ + "Could not get the latest release from github.com: curl: (22) The requested URL returned error: 404", + "No version specified, using latest (1.2.3)", + }, + dontOutput: []string{"GitHub API"}, + }, + { + name: "API when the page does not redirect", + redirectStatus: http.StatusOK, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{""}, + wantOutput: []string{noRedirect}, }, { - name: "GITHUB_TOKEN", - env: []string{"GITHUB_TOKEN=valid-token", "COMPOSER_AUTH={}"}, - wantVersion: "1.2.3", - wantAuth: "Bearer valid-token", - wantOutput: []string{"Using GitHub auth from the GITHUB_TOKEN env variable"}, + name: "GITHUB_TOKEN", + env: []string{"GITHUB_TOKEN=valid-token"}, + redirectStatus: http.StatusOK, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{"Bearer valid-token"}, + wantDownloadAuth: "Bearer valid-token", + wantOutput: []string{"Using GitHub auth from the GITHUB_TOKEN env variable"}, }, { - name: "gh CLI", - env: []string{"GH_FAKE_TOKEN=gh-token"}, - wantVersion: "1.2.3", - wantAuth: "Bearer gh-token", - wantOutput: []string{"Using GitHub auth from the gh CLI"}, + name: "gh CLI", + env: []string{"GH_FAKE_TOKEN=gh-token"}, + redirectStatus: http.StatusOK, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{"Bearer gh-token"}, + wantDownloadAuth: "Bearer gh-token", + wantOutput: []string{"Using GitHub auth from the gh CLI"}, }, { - name: "GITHUB_TOKEN before the gh CLI", - env: []string{"GITHUB_TOKEN=valid-token", "GH_FAKE_TOKEN=gh-token"}, - wantVersion: "1.2.3", - wantAuth: "Bearer valid-token", - dontOutput: []string{"gh CLI"}, + name: "COMPOSER_AUTH", + env: []string{"COMPOSER_AUTH=" + composerAuth}, + redirectStatus: http.StatusOK, + needsJQ: true, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{"Bearer valid-token"}, + wantDownloadAuth: "Bearer valid-token", + wantOutput: []string{"Using GitHub auth from the COMPOSER_AUTH env variable"}, }, { - name: "rejected GITHUB_TOKEN falls through to the gh CLI", - env: []string{"GITHUB_TOKEN=invalid-token", "GH_FAKE_TOKEN=gh-token"}, - wantVersion: "1.2.3", - wantAuth: "Bearer gh-token", + name: "project container auth.json", + homeFiles: map[string]string{".global/auth.json": composerAuth}, + redirectStatus: http.StatusOK, + needsJQ: true, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{"Bearer valid-token"}, + wantDownloadAuth: "Bearer valid-token", + wantOutput: []string{"Using GitHub auth from ", ".global/auth.json"}, + }, + { + name: "COMPOSER_AUTH without a github.com token", + env: []string{`COMPOSER_AUTH={"gitlab-oauth":{"gitlab.com":"x"}}`}, + homeFiles: map[string]string{".global/auth.json": composerAuth}, + redirectStatus: http.StatusOK, + needsJQ: true, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{"Bearer valid-token"}, + wantDownloadAuth: "Bearer valid-token", + wantOutput: []string{".global/auth.json"}, + }, + { + name: "GITHUB_TOKEN before the gh CLI", + env: []string{"GITHUB_TOKEN=valid-token", "GH_FAKE_TOKEN=gh-token"}, + redirectStatus: http.StatusOK, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{"Bearer valid-token"}, + wantDownloadAuth: "Bearer valid-token", + dontOutput: []string{"gh CLI"}, + }, + { + name: "rejected GITHUB_TOKEN falls through to the gh CLI", + env: []string{"GITHUB_TOKEN=invalid-token", "GH_FAKE_TOKEN=gh-token"}, + redirectStatus: http.StatusOK, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{"Bearer invalid-token", "Bearer gh-token"}, + wantDownloadAuth: "Bearer gh-token", wantOutput: []string{ "Ignoring the GitHub token from the GITHUB_TOKEN env variable, as GitHub rejected it", "Using GitHub auth from the gh CLI", }, }, { - name: "rejected gh CLI token falls through to COMPOSER_AUTH", - needsJQ: true, - env: []string{"GH_FAKE_TOKEN=invalid-token", "COMPOSER_AUTH=" + composerAuth}, - wantVersion: "1.2.3", - wantAuth: "Bearer valid-token", + name: "rejected gh CLI token falls through to COMPOSER_AUTH", + env: []string{"GH_FAKE_TOKEN=invalid-token", "COMPOSER_AUTH=" + composerAuth}, + redirectStatus: http.StatusOK, + needsJQ: true, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{"Bearer invalid-token", "Bearer valid-token"}, + wantDownloadAuth: "Bearer valid-token", wantOutput: []string{ "Ignoring the GitHub token from the gh CLI", "Using GitHub auth from the COMPOSER_AUTH env variable", }, }, { - name: "rejected COMPOSER_AUTH token falls through to auth.json", - needsJQ: true, - env: []string{`COMPOSER_AUTH={"github-oauth":{"github.com":"invalid-token"}}`}, - homeFiles: map[string]string{".global/auth.json": composerAuth}, - wantVersion: "1.2.3", - wantAuth: "Bearer valid-token", + name: "rejected COMPOSER_AUTH token falls through to auth.json", + env: []string{`COMPOSER_AUTH={"github-oauth":{"github.com":"invalid-token"}}`}, + homeFiles: map[string]string{".global/auth.json": composerAuth}, + redirectStatus: http.StatusOK, + needsJQ: true, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{"Bearer invalid-token", "Bearer valid-token"}, + wantDownloadAuth: "Bearer valid-token", wantOutput: []string{ "Ignoring the GitHub token from the COMPOSER_AUTH env variable", "Using GitHub auth from ", }, }, { - name: "token check error", - env: []string{"GITHUB_TOKEN=valid-token"}, - rateLimitStatus: http.StatusBadGateway, - wantVersion: "1.2.3", - wantAuth: "Bearer valid-token", - wantOutput: []string{ - "Could not check the GitHub token from the GITHUB_TOKEN env variable (HTTP 502): using it anyway", - }, - }, - { - name: "COMPOSER_AUTH", - needsJQ: true, - env: []string{"COMPOSER_AUTH=" + composerAuth}, - wantVersion: "1.2.3", - wantAuth: "Bearer valid-token", - wantOutput: []string{"Using GitHub auth from the COMPOSER_AUTH env variable"}, - }, - { - name: "project container auth.json", - needsJQ: true, - homeFiles: map[string]string{".global/auth.json": composerAuth}, - wantVersion: "1.2.3", - wantAuth: "Bearer valid-token", - wantOutput: []string{"Using GitHub auth from ", ".global/auth.json"}, - }, - { - name: "COMPOSER_AUTH without a github.com token", - needsJQ: true, - env: []string{"COMPOSER_AUTH={\"gitlab-oauth\":{\"gitlab.com\":\"x\"}}"}, - homeFiles: map[string]string{".global/auth.json": composerAuth}, - wantVersion: "1.2.3", - wantAuth: "Bearer valid-token", - wantOutput: []string{".global/auth.json"}, + name: "rejected token falls through to no token", + env: []string{"GITHUB_TOKEN=invalid-token"}, + redirectStatus: http.StatusOK, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{"Bearer invalid-token", ""}, + wantOutput: []string{"Ignoring the GitHub token from the GITHUB_TOKEN env variable, as GitHub rejected it"}, + dontOutput: []string{"Using GitHub auth"}, }, { - name: "Composer auth JSON is skipped without jq", - env: []string{"COMPOSER_AUTH=" + composerAuth}, - homeFiles: map[string]string{".global/auth.json": composerAuth}, - noJQ: true, - wantVersion: "1.2.3", - wantOutput: []string{"Composer auth was found, but jq is not installed to read a GitHub token from it"}, - dontOutput: []string{"Using GitHub auth"}, + name: "Composer auth JSON is skipped without jq", + env: []string{"COMPOSER_AUTH=" + composerAuth}, + homeFiles: map[string]string{".global/auth.json": composerAuth}, + redirectStatus: http.StatusOK, + noJQ: true, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{""}, + wantOutput: []string{"Composer auth was found, but jq is not installed to read a GitHub token from it"}, + dontOutput: []string{"Using GitHub auth"}, }, { - name: "invalid token", - env: []string{"GITHUB_TOKEN=invalid-token"}, - wantVersion: "1.2.3", - wantOutput: []string{"Ignoring the GitHub token from the GITHUB_TOKEN env variable, as GitHub rejected it"}, - dontOutput: []string{"Using GitHub auth"}, - }, - { - name: "rate limited", - apiStatus: http.StatusForbidden, - wantVersion: "1.2.3", + name: "rate limited", + redirectStatus: http.StatusOK, + apiStatus: http.StatusForbidden, + wantRedirects: 1, + wantAPIAuth: []string{""}, wantOutput: []string{ - "Could not get the latest release from the GitHub API", - "error: 403", + noRedirect, + "Could not get the latest release from the GitHub API: HTTP 403", "set GITHUB_TOKEN to use a token", - "No version specified, using latest (1.2.3)", + "ERROR: Could not determine CLI version", }, }, { - name: "API error", - apiStatus: http.StatusInternalServerError, - wantVersion: "1.2.3", - wantOutput: []string{"error: 500"}, - dontOutput: []string{"set GITHUB_TOKEN"}, + name: "API error", + redirectStatus: http.StatusOK, + apiStatus: http.StatusInternalServerError, + wantRedirects: 1, + wantAPIAuth: []string{""}, + wantOutput: []string{ + "Could not get the latest release from the GitHub API: HTTP 500", + "ERROR: Could not determine CLI version", + }, + dontOutput: []string{"set GITHUB_TOKEN"}, }, { - name: "no version found", - apiStatus: http.StatusForbidden, - noRedirect: true, + name: "API error with a token is not retried", + env: []string{"GITHUB_TOKEN=valid-token", "GH_FAKE_TOKEN=gh-token"}, + redirectStatus: http.StatusOK, + apiStatus: http.StatusBadGateway, + wantRedirects: 1, + wantAPIAuth: []string{"Bearer valid-token"}, wantOutput: []string{ - "error: 403", - "Could not get the latest release from github.com", + "Could not get the latest release from the GitHub API: HTTP 502", "ERROR: Could not determine CLI version", }, + dontOutput: []string{"set GITHUB_TOKEN"}, }, } @@ -383,16 +450,17 @@ func TestInstallerLatestVersion(t *testing.T) { } } g := newFakeGitHub(t) - g.rateLimitStatus = c.rateLimitStatus + g.redirectStatus = c.redirectStatus g.apiStatus = c.apiStatus - g.noRedirect = c.noRedirect r := runInstaller(t, g, c.env, c.homeFiles, c.noJQ) if c.wantVersion == "" { assert.Error(t, r.err) assert.NoFileExists(t, r.binPath) + assert.Empty(t, g.authHeaders(downloadPath)) } else { assertInstalled(t, r, c.wantVersion) + assert.Equal(t, []string{c.wantDownloadAuth}, g.authHeaders(downloadPath), "download") } for _, s := range c.wantOutput { assert.Contains(t, r.output, s) @@ -400,14 +468,12 @@ func TestInstallerLatestVersion(t *testing.T) { for _, s := range c.dontOutput { assert.NotContains(t, r.output, s) } - - latest := g.requestsTo(latestPath) - if slices.ContainsFunc(c.env, func(e string) bool { return strings.HasPrefix(e, "VERSION=") }) { - assert.Empty(t, latest, "a pinned version should not need the API") - return + redirectAuth := g.authHeaders(redirectPath) + assert.Len(t, redirectAuth, c.wantRedirects, "latest release page requests") + for _, auth := range redirectAuth { + assert.Empty(t, auth, "the latest release page is requested without a token") } - require.Len(t, latest, 1) - assert.Equal(t, c.wantAuth, latest[0].auth) + assert.Equal(t, c.wantAPIAuth, g.authHeaders(apiPath), "API requests") }) } } From 009495b1a34d0478e7fc04c84c86f53e3c7132f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 21:05:11 +0000 Subject: [PATCH 7/7] fix(installer): send GITHUB_TOKEN with the download as before A URL (e.g. a private mirror) may need the token. GITHUB_TOKEN is now sent with the download unless GitHub rejected it; tokens from the other sources are only sent if GitHub accepted them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnUBGurqFyfoRBS7aXYqDR --- installer.sh | 13 +++++++------ integration-tests/installer_test.go | 27 +++++++++++++++++++-------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/installer.sh b/installer.sh index 436db26ef..5331350d8 100644 --- a/installer.sh +++ b/installer.sh @@ -31,7 +31,7 @@ vendor_name="Upsun" : "${BREW_TAP:=upsun/tap}" : "${BREW_FORMULA:=upsun/tap/upsun-cli}" -# A GitHub token, for the API (only used if the latest release page does not redirect) +# A GitHub token, for the download, and for the API (only used if the latest release page does not redirect) : "${GITHUB_TOKEN:=}" # CI specifics @@ -40,9 +40,6 @@ vendor_name="Upsun" : "${RUN_ID:=}" # global variables -env_github_token="${GITHUB_TOKEN}" -# A token that GitHub accepted, used for downloads too. -GITHUB_TOKEN="" binary="upsun" # How to run the binary: the full path if its directory is not in $PATH. run_cmd="$binary" @@ -315,7 +312,7 @@ latest_tag_with_token() { case "$token_rc" in 0) output " [*] Using GitHub auth from $2" "success" - # The token is also used to download the release. + # The token is also used for the download. GITHUB_TOKEN="$1" ;; 1) @@ -334,7 +331,11 @@ latest_tag_with_token() { latest_tag_from_api_with_auth() { rc=0 # The env variable comes first, as with the gh CLI itself. - latest_tag_with_token "${env_github_token}" "the GITHUB_TOKEN env variable" || rc=$? + latest_tag_with_token "${GITHUB_TOKEN}" "the GITHUB_TOKEN env variable" || rc=$? + if [ "$rc" = 1 ]; then + # Not sent with the download, if GitHub rejected it. + GITHUB_TOKEN="" + fi if [ "$rc" = 1 ] && command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then rc=0 latest_tag_with_token "$(gh auth token 2>/dev/null || true)" "the gh CLI" || rc=$? diff --git a/integration-tests/installer_test.go b/integration-tests/installer_test.go index 751f80afe..7c30dec32 100644 --- a/integration-tests/installer_test.go +++ b/integration-tests/installer_test.go @@ -229,12 +229,13 @@ func TestInstallerLatestVersion(t *testing.T) { dontOutput []string }{ { - name: "pinned version", - env: []string{"VERSION=1.0.0", "GITHUB_TOKEN=valid-token"}, - wantVersion: "1.0.0", - wantAPIAuth: []string{}, - wantOutput: []string{"Version 1.0.0 specified"}, - dontOutput: []string{"GitHub auth"}, + name: "pinned version", + env: []string{"VERSION=1.0.0", "GITHUB_TOKEN=valid-token"}, + wantVersion: "1.0.0", + wantAPIAuth: []string{}, + wantDownloadAuth: "Bearer valid-token", + wantOutput: []string{"Version 1.0.0 specified"}, + dontOutput: []string{"GitHub auth"}, }, { name: "redirect", @@ -245,8 +246,18 @@ func TestInstallerLatestVersion(t *testing.T) { dontOutput: []string{"Could not", "GitHub auth"}, }, { - name: "redirect without using tokens", - env: []string{"GITHUB_TOKEN=valid-token", "GH_FAKE_TOKEN=gh-token"}, + name: "redirect, with GITHUB_TOKEN only for the download", + env: []string{"GITHUB_TOKEN=valid-token", "GH_FAKE_TOKEN=gh-token"}, + homeFiles: map[string]string{".global/auth.json": composerAuth}, + wantVersion: "1.2.3", + wantRedirects: 1, + wantAPIAuth: []string{}, + wantDownloadAuth: "Bearer valid-token", + dontOutput: []string{"GitHub auth", "GitHub token"}, + }, + { + name: "redirect without sending other tokens", + env: []string{"GH_FAKE_TOKEN=gh-token"}, homeFiles: map[string]string{".global/auth.json": composerAuth}, wantVersion: "1.2.3", wantRedirects: 1,