diff --git a/lib/octokit.js b/lib/octokit.js index 264eaa2b..8e0823e3 100644 --- a/lib/octokit.js +++ b/lib/octokit.js @@ -14,7 +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 { Agent, ProxyAgent, fetch as undiciFetch } from "undici"; import { RETRY_CONF } from "./definitions/retry.js"; import { THROTTLE_CONF } from "./definitions/throttle.js"; @@ -75,7 +75,7 @@ export function toOctokitOptions(options) { ...opts, dispatcher: options.proxy ? new ProxyAgent({ uri: options.proxy }) - : undefined, + : new Agent(), }); return { diff --git a/lib/publish.js b/lib/publish.js index 9a5b623c..2ee3b908 100644 --- a/lib/publish.js +++ b/lib/publish.js @@ -138,7 +138,6 @@ export default async function publish(pluginConfig, context, { Octokit }) { name: fileName, headers: { "content-type": mime.getType(extname(fileName)) || "text/plain", - "content-length": file.size, }, }; diff --git a/test/publish.test.js b/test/publish.test.js index 86bb6b7c..35942043 100644 --- a/test/publish.test.js +++ b/test/publish.test.js @@ -494,6 +494,101 @@ test("Publish a release with one asset", async (t) => { t.true(fetch.done()); }); +test("Publish upload request does not set content-length manually", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITHUB_TOKEN: "github_token" }; + const pluginConfig = { + assets: [".dotfile"], + }; + const nextRelease = { + gitTag: "v1.0.0", + name: "v1.0.0", + notes: "Test release note body", + }; + const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git` }; + const untaggedReleaseUrl = `https://github.com/${owner}/${repo}/releases/untagged-123`; + const releaseUrl = `https://github.com/${owner}/${repo}/releases/${nextRelease.version}`; + const assetUrl = `https://github.com/${owner}/${repo}/releases/download/${nextRelease.version}/.dotfile`; + const releaseId = 1; + const uploadOrigin = "https://uploads.github.local"; + const uploadUri = `/api/uploads/repos/${owner}/${repo}/releases/${releaseId}/assets`; + const uploadUrl = `${uploadOrigin}${uploadUri}{?name,label}`; + const branch = "test_branch"; + const assetUploadUrl = `${uploadOrigin}${uploadUri}?name=${encodeURIComponent(".dotfile")}&`; + + /** @type {Record} */ + let uploadHeaders; + + const fetch = async (url, request = {}) => { + if ( + url === `https://api.github.local/repos/${owner}/${repo}/releases` && + request.method === "POST" + ) { + return new Response( + JSON.stringify({ + upload_url: uploadUrl, + html_url: untaggedReleaseUrl, + id: releaseId, + }), + { headers: { "content-type": "application/json" } }, + ); + } + + if ( + url === + `https://api.github.local/repos/${owner}/${repo}/releases/${releaseId}` && + request.method === "PATCH" + ) { + return new Response(JSON.stringify({ html_url: releaseUrl }), { + headers: { "content-type": "application/json" }, + }); + } + + if (url === assetUploadUrl && request.method === "POST") { + uploadHeaders = request.headers; + return new Response(JSON.stringify({ browser_download_url: assetUrl }), { + headers: { "content-type": "application/json" }, + }); + } + + throw new Error(`Unexpected request ${request.method} ${url}`); + }; + + const result = await publish( + pluginConfig, + { + cwd, + env, + options, + branch: { name: branch, type: "release", main: true }, + nextRelease, + logger: t.context.logger, + }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ); + + t.is(result.url, releaseUrl); + t.true(t.context.log.calledWith("Published GitHub release: %s", releaseUrl)); + t.true(t.context.log.calledWith("Published file %s", assetUrl)); + t.truthy(uploadHeaders); + + const normalizedHeaders = Object.fromEntries( + Object.entries(uploadHeaders).map(([name, value]) => [ + name.toLowerCase(), + String(value), + ]), + ); + + t.is(normalizedHeaders["content-type"], "text/plain"); + t.falsy(normalizedHeaders["content-length"]); +}); + test("Publish a release with one asset and custom github url", async (t) => { const owner = "test_user"; const repo = "test_repo"; diff --git a/test/to-octokit-options.test.js b/test/to-octokit-options.test.js index 57b1dc07..f84c68b3 100644 --- a/test/to-octokit-options.test.js +++ b/test/to-octokit-options.test.js @@ -1,9 +1,8 @@ -import { createServer as _createServer } from "node:https"; +import { createServer } from "node:http"; 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"; @@ -157,3 +156,93 @@ test("only fetch is provided when no proxy is set", async (t) => { auth: "github_token", }); }); + +test("fetch without proxy stays on direct undici dispatcher", async (t) => { + const server = createServer((req, res) => { + req.resume(); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + + const { port } = server.address(); + const url = `http://127.0.0.1:${port}/upload`; + + try { + // Initialize Node's built-in fetch first to mimic real runtime usage. + await fetch(url); + + const options = toOctokitOptions({ + githubToken: "github_token", + githubApiUrl: `http://127.0.0.1:${port}`, + }); + + // throws because the content-length does not match the body length + const error = await t.throwsAsync( + options.request.fetch(url, { + method: "POST", + headers: { "content-length": "10" }, + body: "abc", + }), + ); + + const causeStack = error?.cause?.stack || ""; + t.false(causeStack.includes("node:internal/deps/undici/undici")); + t.true(causeStack.includes("/node_modules/undici/")); + } finally { + await new Promise((resolve) => { + server.close(resolve); + }); + } +}); + +test("fetch function sets content-length for Buffer body", async (t) => { + let requestContentLength; + let receivedBodyLength = 0; + const body = Buffer.from("upload-body"); + + const server = createServer((req, res) => { + requestContentLength = req.headers["content-length"]; + req.on("data", (chunk) => { + receivedBodyLength += chunk.length; + }); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + + const { port } = server.address(); + const url = `http://127.0.0.1:${port}/upload`; + + try { + const options = toOctokitOptions({ + githubToken: "github_token", + githubApiUrl: `http://127.0.0.1:${port}`, + }); + + const response = await options.request.fetch(url, { + method: "POST", + headers: { "content-type": "application/octet-stream" }, + body, + }); + + t.is(response.status, 200); + t.deepEqual(await response.json(), { ok: true }); + t.is(requestContentLength, `${body.byteLength}`); + t.is(receivedBodyLength, body.byteLength); + } finally { + await new Promise((resolve) => { + server.close(resolve); + }); + } +});