From fc08c3315c317c8ec11463ff378cc42a1588bed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Mon, 6 Jul 2026 23:11:05 +0200 Subject: [PATCH 1/2] Ensure `github-token` wins for `git-cli` pushes --- src/github.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/github.ts b/src/github.ts index 54553463..5fbc5879 100644 --- a/src/github.ts +++ b/src/github.ts @@ -86,10 +86,19 @@ export class GitHub { `Invalid GIT_CONFIG_COUNT value: ${process.env.GIT_CONFIG_COUNT}`, ); } + const extraHeaderKey = `http.${serverUrl}/.extraheader`; + const authHeader = `AUTHORIZATION: basic ${basic}`; + return { - GIT_CONFIG_COUNT: String(gitConfigCount + 1), - [`GIT_CONFIG_KEY_${gitConfigCount}`]: `http.${serverUrl}/.extraheader`, - [`GIT_CONFIG_VALUE_${gitConfigCount}`]: `AUTHORIZATION: basic ${basic}`, + GIT_CONFIG_COUNT: String(gitConfigCount + 2), + // Reset inherited extraheaders first. In v1, `github-token` was written + // to ~/.netrc, so checkout's persisted extraheader could win for pushes. + // The ~/.netrc token was effectively only a fallback for git auth. + // Here `github-token` intentionally wins. + [`GIT_CONFIG_KEY_${gitConfigCount}`]: extraHeaderKey, + [`GIT_CONFIG_VALUE_${gitConfigCount}`]: "", + [`GIT_CONFIG_KEY_${gitConfigCount + 1}`]: extraHeaderKey, + [`GIT_CONFIG_VALUE_${gitConfigCount + 1}`]: authHeader, }; } From 0849cdf56b1d64ed5177b94f37baf6d542fb1677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 22 Jul 2026 09:27:14 +0200 Subject: [PATCH 2/2] more shenanigans --- src/github.test.ts | 92 ++++++++++++++++++++++++++++++++++++++++++++++ src/github.ts | 86 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 src/github.test.ts diff --git a/src/github.test.ts b/src/github.test.ts new file mode 100644 index 00000000..bc54d4b5 --- /dev/null +++ b/src/github.test.ts @@ -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")}`, + }), + }), + ); + }); +}); diff --git a/src/github.ts b/src/github.ts index 5fbc5879..e49a4e8c 100644 --- a/src/github.ts +++ b/src/github.ts @@ -50,6 +50,25 @@ const checkIfClean = async (options: GitOptions): Promise => { 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; @@ -71,7 +90,7 @@ export class GitHub { return this.#githubToken; } - #getCliAuthEnv(): Record { + async #getCliAuthEnv(): Promise> { const basic = Buffer.from(`x-access-token:${this.#githubToken}`).toString( "base64", ); @@ -86,20 +105,57 @@ export class GitHub { `Invalid GIT_CONFIG_COUNT value: ${process.env.GIT_CONFIG_COUNT}`, ); } - const extraHeaderKey = `http.${serverUrl}/.extraheader`; - const authHeader = `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, + }, + ); - return { - GIT_CONFIG_COUNT: String(gitConfigCount + 2), - // Reset inherited extraheaders first. In v1, `github-token` was written - // to ~/.netrc, so checkout's persisted extraheader could win for pushes. - // The ~/.netrc token was effectively only a fallback for git auth. - // Here `github-token` intentionally wins. - [`GIT_CONFIG_KEY_${gitConfigCount}`]: extraHeaderKey, - [`GIT_CONFIG_VALUE_${gitConfigCount}`]: "", - [`GIT_CONFIG_KEY_${gitConfigCount + 1}`]: extraHeaderKey, - [`GIT_CONFIG_VALUE_${gitConfigCount + 1}`]: authHeader, + // 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 = { + 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() { @@ -139,7 +195,7 @@ export class GitHub { cwd: this.cwd, env: { ...process.env, - ...this.#getCliAuthEnv(), + ...(await this.#getCliAuthEnv()), } as Record, }); } @@ -174,7 +230,7 @@ export class GitHub { cwd: this.cwd, env: { ...process.env, - ...this.#getCliAuthEnv(), + ...(await this.#getCliAuthEnv()), } as Record, }); }