From 39ad3c89776dd68327979412d8ad0e9bec1805ef Mon Sep 17 00:00:00 2001 From: HyunSoo <84193252+rover0811@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:45:21 +0900 Subject: [PATCH] fix(github): honor GITHUB_GRAPHQL_URL for the GraphQL client (#1575) The REST client honors GITHUB_API_URL, but the GraphQL client derived its base URL from GITHUB_API_URL as well and ignored the standard GITHUB_GRAPHQL_URL variable that GitHub Actions provides. On standard GitHub Enterprise Server this still worked because @octokit/graphql rewrites a ".../api/v3" REST base to ".../api/graphql", but any deployment whose GraphQL endpoint is not derivable from the REST base (custom proxy, separate host) sent GraphQL requests to the wrong URL. Honor GITHUB_GRAPHQL_URL independently and fall back to GITHUB_API_URL when it is unset, so behavior is unchanged for github.com and standard GHES. A single trailing "/graphql" is stripped because @octokit/graphql appends its own. Add wire-level regression tests that run the real client factory in a fresh process and assert the final request URLs and Authorization headers; constructor-option assertions are insufficient because @octokit/graphql rewrites the path after the client is constructed. --- src/github/api/client.ts | 4 +- src/github/api/config.ts | 13 +++ test/fixtures/graphql-endpoint-probe.ts | 41 ++++++++ test/github-graphql-url.test.ts | 121 ++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/graphql-endpoint-probe.ts create mode 100644 test/github-graphql-url.test.ts diff --git a/src/github/api/client.ts b/src/github/api/client.ts index 54370589..e154c07a 100644 --- a/src/github/api/client.ts +++ b/src/github/api/client.ts @@ -1,6 +1,6 @@ import { Octokit } from "@octokit/rest"; import { graphql } from "@octokit/graphql"; -import { GITHUB_API_URL } from "./config"; +import { GITHUB_API_URL, GITHUB_GRAPHQL_URL } from "./config"; export type Octokits = { rest: Octokit; @@ -14,7 +14,7 @@ export function createOctokit(token: string): Octokits { baseUrl: GITHUB_API_URL, }), graphql: graphql.defaults({ - baseUrl: GITHUB_API_URL, + baseUrl: GITHUB_GRAPHQL_URL, headers: { authorization: `token ${token}`, }, diff --git a/src/github/api/config.ts b/src/github/api/config.ts index 9e533e58..56e6639f 100644 --- a/src/github/api/config.ts +++ b/src/github/api/config.ts @@ -2,3 +2,16 @@ export const GITHUB_API_URL = process.env.GITHUB_API_URL || "https://api.github.com"; export const GITHUB_SERVER_URL = process.env.GITHUB_SERVER_URL || "https://github.com"; + +// GraphQL base URL for @octokit/graphql. GitHub Actions exposes the full GraphQL +// endpoint in GITHUB_GRAPHQL_URL (e.g. "https://HOST/api/graphql"), while +// @octokit/graphql appends "/graphql" to whatever baseUrl it is given, so a +// single trailing "/graphql" is stripped here to avoid "/graphql/graphql". +// When GITHUB_GRAPHQL_URL is unset we fall back to GITHUB_API_URL, preserving the +// existing behavior where @octokit/graphql rewrites a REST ".../api/v3" base to +// ".../api/graphql". The trailing-slash trim keeps that rewrite working. +export const GITHUB_GRAPHQL_URL = ( + process.env.GITHUB_GRAPHQL_URL || GITHUB_API_URL +) + .replace(/\/+$/, "") + .replace(/\/graphql$/, ""); diff --git a/test/fixtures/graphql-endpoint-probe.ts b/test/fixtures/graphql-endpoint-probe.ts new file mode 100644 index 00000000..8697206e --- /dev/null +++ b/test/fixtures/graphql-endpoint-probe.ts @@ -0,0 +1,41 @@ +// Wire-level probe for the GitHub API client's endpoint routing. +// +// `src/github/api/config.ts` reads GITHUB_API_URL / GITHUB_GRAPHQL_URL at module +// load time, so each endpoint configuration has to be exercised in its own fresh +// process (the companion test spawns this file once per case with the relevant +// env vars set). We stub global fetch to capture the FINAL request URL and +// Authorization header — asserting constructor options is not enough because +// @octokit/graphql rewrites/append the path (".../api/v3" -> ".../api/graphql", +// otherwise it appends "/graphql") after the client is constructed. +import { createOctokit } from "../../src/github/api/client"; + +type Captured = { url: string; auth: string | null }; +const captured: Captured[] = []; + +globalThis.fetch = (async (input: any, init?: any) => { + const url: string = + typeof input === "string" ? input : (input?.url ?? String(input)); + const headers = new Headers(init?.headers ?? input?.headers); + captured.push({ url, auth: headers.get("authorization") }); + return new Response(JSON.stringify({ data: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +}) as typeof fetch; + +const octokits = createOctokit("test-token"); + +await octokits.graphql(`query { viewer { login } }`); +const graphql = captured[captured.length - 1]!; + +await octokits.rest.request("GET /meta"); +const rest = captured[captured.length - 1]!; + +process.stdout.write( + JSON.stringify({ + graphqlUrl: graphql.url, + graphqlAuth: graphql.auth, + restUrl: rest.url, + restAuth: rest.auth, + }), +); diff --git a/test/github-graphql-url.test.ts b/test/github-graphql-url.test.ts new file mode 100644 index 00000000..37e2dfb6 --- /dev/null +++ b/test/github-graphql-url.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; + +// The GitHub client reads GITHUB_API_URL / GITHUB_GRAPHQL_URL when +// `src/github/api/config.ts` is first imported, so we cannot flip env vars +// between cases inside a single process. Instead each case runs the real +// `createOctokit` factory in a fresh Bun process (test/fixtures/graphql-endpoint-probe.ts) +// with a stubbed fetch that reports the FINAL wire URL and Authorization header. +// +// This is the level that matters: @octokit/graphql derives the GraphQL endpoint +// from its baseUrl AFTER construction (rewriting a REST ".../api/v3" base to +// ".../api/graphql", and otherwise appending "/graphql"), so a constructor-option +// assertion would not catch a regression. + +const PROBE = join(import.meta.dir, "fixtures", "graphql-endpoint-probe.ts"); + +type ProbeResult = { + graphqlUrl: string; + graphqlAuth: string | null; + restUrl: string; + restAuth: string | null; +}; + +function probe(env: Record): ProbeResult { + const result = Bun.spawnSync({ + cmd: ["bun", "run", PROBE], + env: { + ...process.env, + // Start from a clean slate so the host's own env cannot leak in. + GITHUB_API_URL: "", + GITHUB_GRAPHQL_URL: "", + ...env, + }, + stdout: "pipe", + stderr: "pipe", + }); + + if (result.exitCode !== 0) { + throw new Error( + `probe failed (exit ${result.exitCode}): ${result.stderr.toString()}`, + ); + } + + return JSON.parse(result.stdout.toString().trim()) as ProbeResult; +} + +describe("GitHub API client endpoint routing", () => { + test("both env vars unset: REST and GraphQL use github.com", () => { + const r = probe({}); + expect(r.restUrl).toBe("https://api.github.com/meta"); + expect(r.graphqlUrl).toBe("https://api.github.com/graphql"); + }); + + test("GITHUB_API_URL alone (GHES): GraphQL still resolves to /api/graphql", () => { + // Regression guard: @octokit/graphql rewrites a ".../api/v3" REST base to + // ".../api/graphql", so GraphQL must keep working when only GITHUB_API_URL + // is provided (e.g. under `act` or partial configs). + const r = probe({ GITHUB_API_URL: "https://ghe.example.test/api/v3" }); + expect(r.restUrl).toBe("https://ghe.example.test/api/v3/meta"); + expect(r.graphqlUrl).toBe("https://ghe.example.test/api/graphql"); + }); + + test("GITHUB_GRAPHQL_URL alone: GraphQL honors it exactly, REST stays public", () => { + const r = probe({ + GITHUB_GRAPHQL_URL: "https://ghe.example.test/api/graphql", + }); + expect(r.graphqlUrl).toBe("https://ghe.example.test/api/graphql"); + expect(r.restUrl).toBe("https://api.github.com/meta"); + }); + + test("both set to standard GHES values: REST and GraphQL route independently", () => { + const r = probe({ + GITHUB_API_URL: "https://ghe.example.test/api/v3", + GITHUB_GRAPHQL_URL: "https://ghe.example.test/api/graphql", + }); + expect(r.restUrl).toBe("https://ghe.example.test/api/v3/meta"); + expect(r.graphqlUrl).toBe("https://ghe.example.test/api/graphql"); + }); + + test("GITHUB_GRAPHQL_URL wins over a GITHUB_API_URL-derived endpoint", () => { + // Distinguishing case: without honoring GITHUB_GRAPHQL_URL, GraphQL would be + // derived from GITHUB_API_URL and hit the wrong host. + const r = probe({ + GITHUB_API_URL: "https://ghe.example.test/api/v3", + GITHUB_GRAPHQL_URL: "https://gql.example.test/api/graphql", + }); + expect(r.graphqlUrl).toBe("https://gql.example.test/api/graphql"); + expect(r.restUrl).toBe("https://ghe.example.test/api/v3/meta"); + }); + + test("trailing slash on GITHUB_GRAPHQL_URL is normalized", () => { + const r = probe({ + GITHUB_GRAPHQL_URL: "https://ghe.example.test/api/graphql/", + }); + expect(r.graphqlUrl).toBe("https://ghe.example.test/api/graphql"); + }); + + test("GITHUB_GRAPHQL_URL without a /graphql suffix is preserved before the client appends one", () => { + const r = probe({ + GITHUB_GRAPHQL_URL: "https://gql.example.test/custom", + }); + expect(r.graphqlUrl).toBe("https://gql.example.test/custom/graphql"); + }); + + test("a base already ending in /graphql is not doubled", () => { + const r = probe({ + GITHUB_GRAPHQL_URL: "https://gql.example.test/api/graphql", + }); + expect(r.graphqlUrl).not.toContain("/graphql/graphql"); + expect(r.graphqlUrl).toBe("https://gql.example.test/api/graphql"); + }); + + test("the token authorization header is preserved on both clients", () => { + const r = probe({ + GITHUB_API_URL: "https://ghe.example.test/api/v3", + GITHUB_GRAPHQL_URL: "https://ghe.example.test/api/graphql", + }); + expect(r.graphqlAuth).toBe("token test-token"); + expect(r.restAuth).toBe("token test-token"); + }); +});