Ensure github-token wins for git-cli pushes - #692
Conversation
🦋 Changeset detectedLatest commit: 57b6b8d The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| ).toEqual(remote.requests.map(() => [getAuthorization(actionToken)])); | ||
| }, 15_000); | ||
|
|
||
| it("uses the push URL when it differs from the fetch URL", async () => { |
There was a problem hiding this comment.
do we need to test for this? is it a case we handle explicitly?
| // GIT_CONFIG_COUNT/KEY_n/VALUE_n add command-scoped config. Preserve any | ||
| // existing entries and append ours. `http.extraHeader` is multi-valued, so | ||
| // merely adding our Authorization header would make Git send both tokens. | ||
| // An empty value resets the list; the following value adds only our token. | ||
| // | ||
| // In v1, `github-token` lived in ~/.netrc. When checkout had already | ||
| // supplied Authorization through an extraheader, that header took | ||
| // precedence and ~/.netrc was effectively a fallback. These entries | ||
| // intentionally make `github-token` win for pushes. |
There was a problem hiding this comment.
i think a full description of how we override the token and why we do it that way should be written somewhere else, the current doc comments are both duplicated and split up so they are hard to read.
| function recordRequest(request: IncomingMessage): RecordedRequest { | ||
| return { | ||
| method: request.method ?? "GET", | ||
| url: request.url ?? "/", | ||
| headers: request.headersDistinct, | ||
| }; | ||
| } |
There was a problem hiding this comment.
this function should be closer to where it's used
| async function runGitHttpBackend( | ||
| cwd: string, | ||
| request: IncomingMessage, | ||
| response: ServerResponse, | ||
| ) { | ||
| const requestUrl = new URL( | ||
| request.url ?? "/", | ||
| `http://${request.headers.host ?? "localhost"}`, | ||
| ); | ||
| const env: NodeJS.ProcessEnv = { | ||
| ...process.env, | ||
| CONTENT_LENGTH: request.headers["content-length"] ?? "0", | ||
| GATEWAY_INTERFACE: "CGI/1.1", | ||
| GIT_HTTP_EXPORT_ALL: "1", | ||
| GIT_PROJECT_ROOT: cwd, | ||
| PATH_INFO: decodeURIComponent(requestUrl.pathname), | ||
| QUERY_STRING: requestUrl.search.slice(1), | ||
| REMOTE_ADDR: request.socket.remoteAddress ?? "", | ||
| REQUEST_METHOD: request.method ?? "GET", | ||
| SERVER_PROTOCOL: `HTTP/${request.httpVersion}`, | ||
| }; | ||
| if (request.headers["content-type"] !== undefined) { | ||
| env.CONTENT_TYPE = request.headers["content-type"]; | ||
| } | ||
|
|
||
| const backend = spawn("git", ["http-backend"], { | ||
| env, | ||
| stdio: ["pipe", "pipe", "pipe"], | ||
| }); | ||
| request.pipe(backend.stdin); | ||
|
|
||
| const stdout: Buffer[] = []; | ||
| const stderr: Buffer[] = []; | ||
| backend.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); | ||
| backend.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); | ||
|
|
||
| const exitCode = await new Promise<number | null>((resolve, reject) => { | ||
| backend.on("error", reject); | ||
| backend.on("close", resolve); | ||
| }); | ||
| if (exitCode !== 0) { | ||
| throw new Error( | ||
| `git http-backend exited with ${exitCode}: ${Buffer.concat(stderr).toString("utf8")}`, | ||
| ); | ||
| } | ||
|
|
||
| const output = Buffer.concat(stdout); | ||
| let separator = Buffer.from("\r\n\r\n"); | ||
| let headerEnd = output.indexOf(separator); | ||
| if (headerEnd === -1) { | ||
| separator = Buffer.from("\n\n"); | ||
| headerEnd = output.indexOf(separator); | ||
| } | ||
| if (headerEnd === -1) { | ||
| throw new Error("git http-backend returned an invalid CGI response"); | ||
| } | ||
|
|
||
| let status = 200; | ||
| const headers = output.subarray(0, headerEnd).toString("utf8").split(/\r?\n/); | ||
| for (const header of headers) { | ||
| const separatorIndex = header.indexOf(":"); | ||
| if (separatorIndex === -1) continue; | ||
|
|
||
| const name = header.slice(0, separatorIndex); | ||
| const value = header.slice(separatorIndex + 1).trim(); | ||
| if (name.toLowerCase() === "status") { | ||
| status = Number.parseInt(value, 10); | ||
| } else { | ||
| response.setHeader(name, value); | ||
| } | ||
| } | ||
|
|
||
| response.writeHead(status); | ||
| response.end(output.subarray(headerEnd + separator.length)); | ||
| } | ||
|
|
||
| async function listen(server: http.Server) { | ||
| const waiter = Promise.withResolvers(); | ||
|
|
||
| server.on("listening", waiter.resolve); | ||
| server.on("error", waiter.reject); | ||
|
|
||
| server.listen(0); | ||
|
|
||
| try { | ||
| await waiter.promise; | ||
| return server; | ||
| } finally { | ||
| server.off("listening", waiter.resolve); | ||
| server.off("error", waiter.reject); | ||
| } | ||
| } | ||
|
|
||
| async function createGitHttpServer(cwd: string) { | ||
| const requests: RecordedRequest[] = []; | ||
| const server = http.createServer((request, response) => { | ||
| const recordedRequest = recordRequest(request); | ||
| requests.push(recordedRequest); | ||
|
|
||
| void runGitHttpBackend(cwd, request, response).catch((error: unknown) => { | ||
| response.destroy( | ||
| Error.isError(error) | ||
| ? error | ||
| : new Error("Server error", { cause: error }), | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| await listen(server); | ||
| const address = server.address(); | ||
| assert( | ||
| !!address && typeof address !== "string", | ||
| "Failed to get server address", | ||
| ); | ||
|
|
||
| return { | ||
| origin: `http://127.0.0.1:${address.port}`, | ||
| requests, | ||
| async [Symbol.asyncDispose]() { | ||
| await new Promise<void>((resolve, reject) => { | ||
| server.close((error) => { | ||
| if (error) { | ||
| reject(error); | ||
| return; | ||
| } | ||
| resolve(); | ||
| }); | ||
| }); | ||
| }, | ||
| }; | ||
| } |
There was a problem hiding this comment.
this should probably be in its own file and split up a bit.
i would also prefer if we use srvx so we can use Request/Response instead of node's http server
There was a problem hiding this comment.
IMO node's createServer isn't bad and we shouldn't try to avoid it. Its API is not the most modern, but we don't need to risk running a server framework that might have its own issues, plus Request-Response-based servers also have their own limitations which don't exist in createServer.
There was a problem hiding this comment.
i just prefer Requst/Response over node's api 😜
srvx is the one used by h3/nuxt (soon?), so i think it'd be fine for our use case in tests.
like i wrote before, i'm getting overwhelmed by the test setups we're creating so anything to simplify them would be a win in my eyes
There was a problem hiding this comment.
i think these test files are getting a bit overwhelming... my brain struggles massively to read them
This should fix auth headers when the action gets used with
actions/checkout's defaultpersist-credentials: truerelated to sveltejs/kit#16248