mirror of
https://github.com/changesets/action
synced 2026-10-26 16:39:56 +08:00
Use GitHub API for pushes by default and require explicit github-token when using custom tokens (#692)
* Ensure `github-token` wins for `git-cli` pushes * more shenanigans * Default to GitHub API commit mode * e2e auth tests * push missing utils * rename input * refactor * refactor * refactor * refactor * refactor * refactor * reuse helpers * refactor * refactor * simplify * add tests * refactor * improve comments * shift helper * add comments * extract test helpers * update docs and changesets * update one thing
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import fsp from "node:fs/promises";
|
||||
import http, { type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createFixture, type FileTree } from "fs-fixture";
|
||||
import { exec } from "tinyexec";
|
||||
import { moveDisposable } from "./utils.ts";
|
||||
|
||||
export type Fixture = FileTree;
|
||||
|
||||
export async function testdir(dir?: Fixture) {
|
||||
return createFixture(dir, {
|
||||
fs: {
|
||||
...fsp,
|
||||
rm: (filePath, options) =>
|
||||
fsp.rm(filePath, {
|
||||
maxRetries: 3,
|
||||
retryDelay: 100,
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Git maintenance can race with fixture cleanup by touching pack files.
|
||||
export async function disableGitBackgroundMaintenance(cwd: string) {
|
||||
await exec("git", ["config", "gc.auto", "0"], {
|
||||
nodeOptions: { cwd },
|
||||
throwOnError: true,
|
||||
});
|
||||
await exec("git", ["config", "maintenance.auto", "false"], {
|
||||
nodeOptions: { cwd },
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function gitdir(dir: Fixture) {
|
||||
await using stack = new AsyncDisposableStack();
|
||||
const fixture = stack.use(
|
||||
await testdir({
|
||||
".gitattributes": "* text=auto eol=lf\n",
|
||||
...dir,
|
||||
}),
|
||||
);
|
||||
const cwd = fixture.path;
|
||||
|
||||
await exec("git", ["init"], {
|
||||
nodeOptions: { cwd },
|
||||
throwOnError: true,
|
||||
});
|
||||
await disableGitBackgroundMaintenance(cwd);
|
||||
|
||||
const { stdout } = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
nodeOptions: { cwd },
|
||||
});
|
||||
if (stdout.trim() !== "main") {
|
||||
await exec("git", ["checkout", "-b", "main"], {
|
||||
nodeOptions: { cwd },
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
|
||||
const gitConfig = `
|
||||
[user]
|
||||
email = x@y.z
|
||||
name = xyz
|
||||
[commit]
|
||||
gpgSign = false
|
||||
[tag]
|
||||
gpgSign = false
|
||||
forceSignAnnotated = false
|
||||
`.trim();
|
||||
await fsp.appendFile(path.join(cwd, ".git/config"), gitConfig, "utf8");
|
||||
|
||||
await exec("git", ["add", "."], {
|
||||
nodeOptions: { cwd },
|
||||
throwOnError: true,
|
||||
});
|
||||
await exec("git", ["commit", "-m", "initial commit", "--allow-empty"], {
|
||||
nodeOptions: { cwd },
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
return moveDisposable(stack, fixture);
|
||||
}
|
||||
|
||||
export async function shallowClone(cwd: string, depth = 1) {
|
||||
await using stack = new AsyncDisposableStack();
|
||||
const fixture = stack.use(await testdir());
|
||||
await exec(
|
||||
"git",
|
||||
["clone", "--depth", depth.toString(), pathToFileURL(cwd).toString(), "."],
|
||||
{
|
||||
nodeOptions: { cwd: fixture.path },
|
||||
throwOnError: true,
|
||||
},
|
||||
);
|
||||
await disableGitBackgroundMaintenance(fixture.path);
|
||||
return moveDisposable(stack, fixture);
|
||||
}
|
||||
|
||||
async function runGitHttpBackend(
|
||||
cwd: string,
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
) {
|
||||
// `git http-backend` speaks CGI: request metadata goes through environment
|
||||
// variables, the request body through stdin, and the response through stdout.
|
||||
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);
|
||||
|
||||
// Buffer stdout so the CGI headers can be separated from the response body.
|
||||
// Keep stderr only to explain a backend failure.
|
||||
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);
|
||||
// CGI ends its response headers with a blank line; accept CRLF and LF output.
|
||||
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");
|
||||
}
|
||||
|
||||
// Translate CGI status and headers, then forward the remaining bytes as body.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
type RecordedRequest = {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: NodeJS.Dict<string[]>;
|
||||
};
|
||||
|
||||
function recordRequest(request: IncomingMessage): RecordedRequest {
|
||||
return {
|
||||
method: request.method ?? "GET",
|
||||
url: request.url ?? "/",
|
||||
headers: request.headersDistinct,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createLocalRemote(dir: Fixture) {
|
||||
await using stack = new AsyncDisposableStack();
|
||||
const fixture = stack.use(await testdir());
|
||||
const remote = fixture.path;
|
||||
{
|
||||
// Use a working repository to create the bare remote's initial history.
|
||||
// Once cloned, the remote owns that history and the source can be disposed.
|
||||
await using sourceFixture = await gitdir(dir);
|
||||
await exec(
|
||||
"git",
|
||||
["clone", "--bare", pathToFileURL(sourceFixture.path).toString(), "."],
|
||||
{
|
||||
nodeOptions: { cwd: remote },
|
||||
throwOnError: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
await disableGitBackgroundMaintenance(remote);
|
||||
await exec("git", ["config", "http.receivepack", "true"], {
|
||||
nodeOptions: { cwd: remote },
|
||||
throwOnError: true,
|
||||
});
|
||||
return moveDisposable(stack, fixture);
|
||||
}
|
||||
|
||||
export async function createGitHttpRemote(files: Fixture) {
|
||||
await using stack = new AsyncDisposableStack();
|
||||
const fixture = stack.use(await createLocalRemote(files));
|
||||
const server = stack.use(
|
||||
await createGitHttpServer(path.dirname(fixture.path)),
|
||||
);
|
||||
|
||||
return moveDisposable(stack, {
|
||||
path: fixture.path,
|
||||
url: `${server.origin}/${path.basename(fixture.path)}`,
|
||||
requests: server.requests,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user