From 5ab1fac37c580cb4b135c14f54d22617b39b5fef Mon Sep 17 00:00:00 2001 From: Claes Merin Date: Thu, 12 Feb 2026 18:28:44 +0100 Subject: [PATCH 1/3] fix: resolve gh CLI token lookup for GHEC (.ghe.com) hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tokenFromGHCLI only mapped api.github.com → github.com before calling `gh auth token --hostname`. For GHEC hosts (api..ghe.com), the api. prefix was not stripped, causing the CLI lookup to fail silently and the provider to fall back to an unauthenticated client (401 errors). Strip the api. prefix for GHEC hosts using the existing GHECAPIHostMatch regex so the hostname matches how `gh` stores credentials. Fixes #3188 --- github/provider.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/github/provider.go b/github/provider.go index 2edcca6079..3f7dc705e8 100644 --- a/github/provider.go +++ b/github/provider.go @@ -506,6 +506,8 @@ func tokenFromGHCLI(u *url.URL) string { host := u.Host if host == DotComAPIHost { host = DotComHost + } else if GHECAPIHostMatch.MatchString(host) { + host = strings.TrimPrefix(host, "api.") } out, err := exec.Command(ghCliPath, "auth", "token", "--hostname", host).Output() From c606c902cb85296c13c89b25b9f8ce935e46b311 Mon Sep 17 00:00:00 2001 From: Claes Merin Date: Thu, 12 Feb 2026 20:00:19 +0100 Subject: [PATCH 2/3] Add test cases --- github/provider_test.go | 65 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/github/provider_test.go b/github/provider_test.go index caca4fdb7d..0a1f9c143d 100644 --- a/github/provider_test.go +++ b/github/provider_test.go @@ -2,6 +2,9 @@ package github import ( "fmt" + "net/url" + "os" + "path/filepath" "regexp" "testing" @@ -259,3 +262,65 @@ data "github_ip_ranges" "test" {} }) }) } + +func Test_tokenFromGHCLI(t *testing.T) { + // Create a fake gh CLI script that echoes back the hostname it receives. + // tokenFromGHCLI calls: gh auth token --hostname + // Our fake script extracts the hostname argument and prints it as the "token". + tmpDir := t.TempDir() + fakeGH := filepath.Join(tmpDir, "gh") + err := os.WriteFile(fakeGH, []byte("#!/bin/sh\necho \"$4\"\n"), 0755) + if err != nil { + t.Fatalf("failed to create fake gh script: %s", err) + } + + testCases := []struct { + name string + url string + expectedHost string + }{ + { + name: "dotcom API host is mapped to dotcom host", + url: "https://api.github.com/", + expectedHost: "github.com", + }, + { + name: "ghec API host has api. prefix stripped", + url: "https://api.my-enterprise.ghe.com/", + expectedHost: "my-enterprise.ghe.com", + }, + { + name: "ghec API host with numbers has api. prefix stripped", + url: "https://api.customer-123.ghe.com/", + expectedHost: "customer-123.ghe.com", + }, + { + name: "ghes host is passed through unchanged", + url: "https://github.example.com/", + expectedHost: "github.example.com", + }, + { + name: "ghes host with port is passed through unchanged", + url: "https://github.example.com:8443/", + expectedHost: "github.example.com:8443", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("GH_PATH", fakeGH) + + u, err := url.Parse(tc.url) + if err != nil { + t.Fatalf("failed to parse URL %q: %s", tc.url, err) + } + + // tokenFromGHCLI returns the trimmed output of the fake script, + // which is the hostname argument passed to `gh auth token --hostname`. + got := tokenFromGHCLI(u) + if got != tc.expectedHost { + t.Errorf("tokenFromGHCLI(%q): hostname passed to gh CLI = %q, want %q", tc.url, got, tc.expectedHost) + } + }) + } +} \ No newline at end of file From c90767ed76d64ddee9d20cbec99e780238423692 Mon Sep 17 00:00:00 2001 From: Claes Merin Date: Fri, 20 Feb 2026 15:11:26 +0100 Subject: [PATCH 3/3] Code updates according to PR feedback - move hostname modification to separate function for easier testing - test omdified to only test ghCLIHostFromAPIHost() without OS specific scripts - line ending fixed --- github/provider.go | 20 ++++++++++++++------ github/provider_test.go | 42 ++++++++++------------------------------- 2 files changed, 24 insertions(+), 38 deletions(-) diff --git a/github/provider.go b/github/provider.go index 3f7dc705e8..751cf14f5d 100644 --- a/github/provider.go +++ b/github/provider.go @@ -496,6 +496,19 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { } } +// ghCLIHostFromAPIHost maps an API hostname to the corresponding +// gh-CLI --hostname value. For example api.github.com -> github.com +// and api..ghe.com -> .ghe.com. +// for unrecognized hostnames, input is returned unmodified. +func ghCLIHostFromAPIHost(host string) string { + if host == DotComAPIHost { + return DotComHost + } else if GHECAPIHostMatch.MatchString(host) { + return strings.TrimPrefix(host, "api.") + } + return host +} + // See https://github.com/integrations/terraform-provider-github/issues/1822 func tokenFromGHCLI(u *url.URL) string { ghCliPath := os.Getenv("GH_PATH") @@ -503,12 +516,7 @@ func tokenFromGHCLI(u *url.URL) string { ghCliPath = "gh" } - host := u.Host - if host == DotComAPIHost { - host = DotComHost - } else if GHECAPIHostMatch.MatchString(host) { - host = strings.TrimPrefix(host, "api.") - } + host := ghCLIHostFromAPIHost(u.Host) out, err := exec.Command(ghCliPath, "auth", "token", "--hostname", host).Output() if err != nil { diff --git a/github/provider_test.go b/github/provider_test.go index 0a1f9c143d..79ddc50583 100644 --- a/github/provider_test.go +++ b/github/provider_test.go @@ -2,9 +2,6 @@ package github import ( "fmt" - "net/url" - "os" - "path/filepath" "regexp" "testing" @@ -263,64 +260,45 @@ data "github_ip_ranges" "test" {} }) } -func Test_tokenFromGHCLI(t *testing.T) { - // Create a fake gh CLI script that echoes back the hostname it receives. - // tokenFromGHCLI calls: gh auth token --hostname - // Our fake script extracts the hostname argument and prints it as the "token". - tmpDir := t.TempDir() - fakeGH := filepath.Join(tmpDir, "gh") - err := os.WriteFile(fakeGH, []byte("#!/bin/sh\necho \"$4\"\n"), 0755) - if err != nil { - t.Fatalf("failed to create fake gh script: %s", err) - } - +func Test_ghCLIHostFromAPIHost(t *testing.T) { testCases := []struct { name string - url string + host string expectedHost string }{ { name: "dotcom API host is mapped to dotcom host", - url: "https://api.github.com/", + host: "api.github.com", expectedHost: "github.com", }, { name: "ghec API host has api. prefix stripped", - url: "https://api.my-enterprise.ghe.com/", + host: "api.my-enterprise.ghe.com", expectedHost: "my-enterprise.ghe.com", }, { name: "ghec API host with numbers has api. prefix stripped", - url: "https://api.customer-123.ghe.com/", + host: "api.customer-123.ghe.com", expectedHost: "customer-123.ghe.com", }, { name: "ghes host is passed through unchanged", - url: "https://github.example.com/", + host: "github.example.com", expectedHost: "github.example.com", }, { name: "ghes host with port is passed through unchanged", - url: "https://github.example.com:8443/", + host: "github.example.com:8443", expectedHost: "github.example.com:8443", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - t.Setenv("GH_PATH", fakeGH) - - u, err := url.Parse(tc.url) - if err != nil { - t.Fatalf("failed to parse URL %q: %s", tc.url, err) - } - - // tokenFromGHCLI returns the trimmed output of the fake script, - // which is the hostname argument passed to `gh auth token --hostname`. - got := tokenFromGHCLI(u) + got := ghCLIHostFromAPIHost(tc.host) if got != tc.expectedHost { - t.Errorf("tokenFromGHCLI(%q): hostname passed to gh CLI = %q, want %q", tc.url, got, tc.expectedHost) + t.Errorf("ghCLIHostFromAPIHost(%q) = %q, want %q", tc.host, got, tc.expectedHost) } }) } -} \ No newline at end of file +}