mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-22 03:18:54 +08:00
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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import { graphql } from "@octokit/graphql";
|
import { graphql } from "@octokit/graphql";
|
||||||
import { GITHUB_API_URL } from "./config";
|
import { GITHUB_API_URL, GITHUB_GRAPHQL_URL } from "./config";
|
||||||
|
|
||||||
export type Octokits = {
|
export type Octokits = {
|
||||||
rest: Octokit;
|
rest: Octokit;
|
||||||
@@ -14,7 +14,7 @@ export function createOctokit(token: string): Octokits {
|
|||||||
baseUrl: GITHUB_API_URL,
|
baseUrl: GITHUB_API_URL,
|
||||||
}),
|
}),
|
||||||
graphql: graphql.defaults({
|
graphql: graphql.defaults({
|
||||||
baseUrl: GITHUB_API_URL,
|
baseUrl: GITHUB_GRAPHQL_URL,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `token ${token}`,
|
authorization: `token ${token}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,3 +2,16 @@ export const GITHUB_API_URL =
|
|||||||
process.env.GITHUB_API_URL || "https://api.github.com";
|
process.env.GITHUB_API_URL || "https://api.github.com";
|
||||||
export const GITHUB_SERVER_URL =
|
export const GITHUB_SERVER_URL =
|
||||||
process.env.GITHUB_SERVER_URL || "https://github.com";
|
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$/, "");
|
||||||
|
|||||||
+41
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -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<string, string>): 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user