diff --git a/lib/octokit.js b/lib/octokit.js index 6bb6db1c..264eaa2b 100644 --- a/lib/octokit.js +++ b/lib/octokit.js @@ -14,6 +14,7 @@ import { throttling } from "@octokit/plugin-throttling"; import urljoin from "url-join"; import { HttpProxyAgent } from "http-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent"; +import { ProxyAgent, fetch as undiciFetch } from "undici"; import { RETRY_CONF } from "./definitions/retry.js"; import { THROTTLE_CONF } from "./definitions/throttle.js"; @@ -50,7 +51,7 @@ export const SemanticReleaseOctokit = Octokit.plugin( /** * @param {{githubToken: string, proxy: any} | {githubUrl: string, githubApiPathPrefix: string, githubApiUrl: string,githubToken: string, proxy: any}} options - * @returns {{ auth: string, baseUrl?: string, request: { agent?: any } }} + * @returns {{ auth: string, baseUrl?: string, request: { agent?: any, fetch?: any } }} */ export function toOctokitOptions(options) { const baseUrl = @@ -69,11 +70,17 @@ export function toOctokitOptions(options) { : new HttpsProxyAgent(options.proxy, options.proxy) : undefined; + const fetchWithDispatcher = (url, opts) => + undiciFetch(url, { + ...opts, + dispatcher: options.proxy + ? new ProxyAgent({ uri: options.proxy }) + : undefined, + }); + return { ...(baseUrl ? { baseUrl } : {}), auth: options.githubToken, - request: { - agent, - }, + request: { agent, fetch: fetchWithDispatcher }, }; } diff --git a/package-lock.json b/package-lock.json index 52621c51..a0e61ef1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "mime": "^4.0.0", "p-filter": "^4.0.0", "tinyglobby": "^0.2.14", + "undici": "^7.16.0", "url-join": "^5.0.0" }, "devDependencies": { @@ -15087,6 +15088,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", diff --git a/package.json b/package.json index 2886f67d..de7d6b78 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "mime": "^4.0.0", "p-filter": "^4.0.0", "tinyglobby": "^0.2.14", + "undici": "^7.0.0", "url-join": "^5.0.0" }, "devDependencies": { diff --git a/test/octokit-proxy-integration.test.js b/test/octokit-proxy-integration.test.js new file mode 100644 index 00000000..6897b2ed --- /dev/null +++ b/test/octokit-proxy-integration.test.js @@ -0,0 +1,89 @@ +import { createServer } from "node:http"; +import { createServer as createHttpsServer } from "node:https"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import test from "ava"; +import { SemanticReleaseOctokit, toOctokitOptions } from "../lib/octokit.js"; + +process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0; + +test("Octokit proxy setup creates proper fetch function", async (t) => { + // This test verifies that the proxy setup creates the expected function structure + // without actually testing network connectivity which can be flaky in CI environments + + const options = toOctokitOptions({ + githubToken: "test_token", + githubApiUrl: "https://api.github.com", + proxy: "http://proxy.example.com:8080", + }); + + const octokit = new SemanticReleaseOctokit(options); + + // Verify that the options are set up correctly + t.true(typeof options.request.fetch === "function"); + t.is(options.auth, "test_token"); + t.is(options.baseUrl, "https://api.github.com"); + + // Verify that both agent (for backwards compatibility) and fetch are present + t.truthy(options.request.agent); + t.truthy(options.request.fetch); + + // Verify that the fetch function has the correct signature + t.is(options.request.fetch.length, 2); +}); + +test("Octokit works without proxy using custom fetch", async (t) => { + let requestReceived = false; + + // Create a mock GitHub API server + const mockApiServer = createServer((req, res) => { + requestReceived = true; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + id: 1, + tag_name: "v1.0.0", + name: "Test Release", + body: "Test release body", + }), + ); + }); + + await new Promise((resolve) => { + mockApiServer.listen(0, "127.0.0.1", resolve); + }); + + const apiPort = mockApiServer.address().port; + + try { + const options = toOctokitOptions({ + githubToken: "test_token", + githubApiUrl: `http://127.0.0.1:${apiPort}`, + // No proxy specified + }); + + const octokit = new SemanticReleaseOctokit(options); + + // Test that the custom fetch function is still created (even without proxy) + t.true(typeof options.request.fetch === "function"); + + const response = await options.request.fetch( + `http://127.0.0.1:${apiPort}/test`, + { + method: "GET", + headers: { + Authorization: "token test_token", + }, + }, + ); + + t.is(response.status, 200); + t.true(requestReceived); + + const data = await response.json(); + t.is(data.tag_name, "v1.0.0"); + } finally { + mockApiServer.close(); + } +}); diff --git a/test/to-octokit-options.test.js b/test/to-octokit-options.test.js index 38c3a4b8..57b1dc07 100644 --- a/test/to-octokit-options.test.js +++ b/test/to-octokit-options.test.js @@ -3,6 +3,7 @@ import { createServer as _createServer } from "node:https"; import test from "ava"; import { HttpProxyAgent } from "http-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent"; +import { ProxyAgent } from "undici"; import { toOctokitOptions } from "../lib/octokit.js"; @@ -67,3 +68,92 @@ test("githubApiUrl with trailing slash", async (t) => { }); t.is(options.baseUrl, "http://api.localhost:10001"); }); + +test("fetch function uses ProxyAgent with proxy", async (t) => { + const proxyUrl = "http://localhost:1002"; + const options = toOctokitOptions({ + githubToken: "github_token", + githubUrl: "https://localhost:10001", + githubApiPathPrefix: "", + proxy: proxyUrl, + }); + + t.true(typeof options.request.fetch === "function"); + + // Test that the fetch function is created and different from the default undici fetch + const { fetch: undiciFetch } = await import("undici"); + t.not(options.request.fetch, undiciFetch); +}); + +test("fetch function does not use ProxyAgent without proxy", async (t) => { + const options = toOctokitOptions({ + githubToken: "github_token", + githubUrl: "https://localhost:10001", + githubApiPathPrefix: "", + }); + + t.true(typeof options.request.fetch === "function"); + + // Test that the fetch function is created and different from the default undici fetch + const { fetch: undiciFetch } = await import("undici"); + t.not(options.request.fetch, undiciFetch); +}); + +test("fetch function preserves original fetch options", async (t) => { + const proxyUrl = "http://localhost:1002"; + const options = toOctokitOptions({ + githubToken: "github_token", + proxy: proxyUrl, + }); + + // Test that we get a custom fetch function when proxy is set + t.true(typeof options.request.fetch === "function"); + + // Test that we can call the function without errors (even though we can't mock the actual fetch) + t.notThrows(() => { + const fetchFn = options.request.fetch; + // Just verify it's a function that can be called with the expected signature + t.is(typeof fetchFn, "function"); + t.is(fetchFn.length, 2); // fetch function should accept 2 parameters (url, options) + }); +}); + +test("both agent and fetch are provided for backwards compatibility", async (t) => { + const proxyUrl = "http://localhost:1002"; + const options = toOctokitOptions({ + githubToken: "github_token", + githubUrl: "https://localhost:10001", + githubApiPathPrefix: "", + proxy: proxyUrl, + }); + + const { request, ...rest } = options; + + // Should have both agent and fetch for compatibility + t.true(request.agent instanceof HttpsProxyAgent); + t.true(typeof request.fetch === "function"); + + t.deepEqual(rest, { + baseUrl: "https://localhost:10001", + auth: "github_token", + }); +}); + +test("only fetch is provided when no proxy is set", async (t) => { + const options = toOctokitOptions({ + githubToken: "github_token", + githubUrl: "https://localhost:10001", + githubApiPathPrefix: "", + }); + + const { request, ...rest } = options; + + // Should have fetch function but no agent when no proxy + t.is(request.agent, undefined); + t.true(typeof request.fetch === "function"); + + t.deepEqual(rest, { + baseUrl: "https://localhost:10001", + auth: "github_token", + }); +});