Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/github.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Buffer } from "node:buffer";
import { exec, getExecOutput } from "@actions/exec";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GitHub } from "./github.ts";

vi.mock("@actions/exec", () => ({
exec: vi.fn(),
getExecOutput: vi.fn(),
}));

vi.mock("@actions/github", () => ({
context: {
repo: {
owner: "changesets",
repo: "action",
},
serverUrl: "https://github.com",
sha: "base-sha",
},
getOctokit: () => ({}),
}));

beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});

describe("GitHub", () => {
it("clears inherited git auth headers before adding the github-token header", async () => {
vi.mocked(getExecOutput)
.mockResolvedValueOnce({
exitCode: 0,
stdout: "",
stderr: "",
})
.mockResolvedValueOnce({
exitCode: 0,
stdout:
"https://x-access-token:remote-token@github.com/changesets/action\n",
stderr: "",
});
vi.mocked(exec).mockResolvedValue(0);
vi.stubEnv("GIT_CONFIG_COUNT", "1");
vi.stubEnv("GIT_CONFIG_KEY_0", "http.https://github.com/.extraheader");
vi.stubEnv("GIT_CONFIG_VALUE_0", "AUTHORIZATION: basic checkout-token");

const github = new GitHub({
cwd: "/repo",
githubToken: "custom-token",
commitMode: "git-cli",
});

await github.pushChanges({
branch: "changeset-release/main",
message: "Version Packages",
});

expect(getExecOutput).toHaveBeenNthCalledWith(
2,
"git",
["remote", "get-url", "--push", "--all", "origin"],
{
cwd: "/repo",
ignoreReturnCode: true,
silent: true,
},
);
expect(exec).toHaveBeenCalledWith(
"git",
["push", "origin", "HEAD:changeset-release/main", "--force"],
expect.objectContaining({
env: expect.objectContaining({
GIT_CONFIG_COUNT: "5",
GIT_CONFIG_KEY_1: "http.https://github.com/.extraheader",
GIT_CONFIG_VALUE_1: "",
GIT_CONFIG_KEY_2: "http.https://github.com/.extraheader",
GIT_CONFIG_VALUE_2: `AUTHORIZATION: basic ${Buffer.from(
"x-access-token:custom-token",
).toString("base64")}`,
GIT_CONFIG_KEY_3:
"http.https://x-access-token@github.com/changesets/action.extraheader",
GIT_CONFIG_VALUE_3: "",
GIT_CONFIG_KEY_4:
"http.https://x-access-token@github.com/changesets/action.extraheader",
GIT_CONFIG_VALUE_4: `AUTHORIZATION: basic ${Buffer.from(
"x-access-token:custom-token",
).toString("base64")}`,
}),
}),
);
});
});
79 changes: 72 additions & 7 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ const checkIfClean = async (options: GitOptions): Promise<boolean> => {
return !stdout.length;
};

function getHttpUrl(remoteUrl: string): string | undefined {
try {
const url = new URL(remoteUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return;
}

// Git includes the username when deciding which URL-specific config is
// most specific, so retain it. Password, query, and fragment do not
// participate in matching; strip them before copying the URL into the env.
url.password = "";
url.search = "";
url.hash = "";
return url.href;
} catch {
return;
}
}

export class GitHub {
readonly #githubToken: string;
readonly octokit: Octokit;
Expand All @@ -71,7 +90,7 @@ export class GitHub {
return this.#githubToken;
}

#getCliAuthEnv(): Record<string, string> {
async #getCliAuthEnv(): Promise<Record<string, string>> {
const basic = Buffer.from(`x-access-token:${this.#githubToken}`).toString(
"base64",
);
Expand All @@ -86,11 +105,57 @@ export class GitHub {
`Invalid GIT_CONFIG_COUNT value: ${process.env.GIT_CONFIG_COUNT}`,
);
}
return {
GIT_CONFIG_COUNT: String(gitConfigCount + 1),
[`GIT_CONFIG_KEY_${gitConfigCount}`]: `http.${serverUrl}/.extraheader`,
[`GIT_CONFIG_VALUE_${gitConfigCount}`]: `AUTHORIZATION: basic ${basic}`,
// `git push origin` may use remote.origin.pushurl instead of the fetch URL,
// and Git supports multiple push URLs. Ask Git for the effective targets so
// the URL-specific auth below applies to every HTTP destination.
const { stdout } = await getExecOutput(
"git",
["remote", "get-url", "--push", "--all", "origin"],
{
cwd: this.cwd,
ignoreReturnCode: true,
// A user-configured remote can contain credentials.
silent: true,
},
);

// Git chooses HTTP config by URL specificity. The host key handles the
// extraheader normally installed by actions/checkout, while an exact push
// URL also outranks any inherited path-specific extraheader. Only the most
// specific matching subsection contributes, so these do not duplicate it.
const extraHeaderKeys = new Set([`http.${serverUrl}/.extraheader`]);
for (const remoteUrl of stdout.split(/\r?\n/)) {
const httpUrl = getHttpUrl(remoteUrl);
if (httpUrl !== undefined) {
extraHeaderKeys.add(`http.${httpUrl}.extraheader`);
}
}
const authHeader = `AUTHORIZATION: basic ${basic}`;
const env: Record<string, string> = {
GIT_CONFIG_COUNT: String(gitConfigCount + extraHeaderKeys.size * 2),
};

// 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.
let index = 0;
for (const extraHeaderKey of extraHeaderKeys) {
const resetIndex = gitConfigCount + index * 2;
const authIndex = resetIndex + 1;
env[`GIT_CONFIG_KEY_${resetIndex}`] = extraHeaderKey;
env[`GIT_CONFIG_VALUE_${resetIndex}`] = "";
env[`GIT_CONFIG_KEY_${authIndex}`] = extraHeaderKey;
env[`GIT_CONFIG_VALUE_${authIndex}`] = authHeader;
index++;
}

return env;
}

async setupUser() {
Expand Down Expand Up @@ -130,7 +195,7 @@ export class GitHub {
cwd: this.cwd,
env: {
...process.env,
...this.#getCliAuthEnv(),
...(await this.#getCliAuthEnv()),
} as Record<string, string>,
});
}
Expand Down Expand Up @@ -165,7 +230,7 @@ export class GitHub {
cwd: this.cwd,
env: {
...process.env,
...this.#getCliAuthEnv(),
...(await this.#getCliAuthEnv()),
} as Record<string, string>,
});
}
Expand Down