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 01/24] 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 02/24] 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, }); } From 2a7a191427baaddb61509e3ff4d2a18b124e8475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 29 Jul 2026 00:44:31 +0200 Subject: [PATCH 03/24] Default to GitHub API commit mode --- .changeset/brave-pandas-commit.md | 5 +++++ README.md | 2 +- action.yml | 2 +- src/github.test.ts | 9 +++++++++ src/github.ts | 2 +- src/index.ts | 2 +- src/version/index.ts | 2 +- version/action.yml | 2 +- 8 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 .changeset/brave-pandas-commit.md diff --git a/.changeset/brave-pandas-commit.md b/.changeset/brave-pandas-commit.md new file mode 100644 index 00000000..b4ce181f --- /dev/null +++ b/.changeset/brave-pandas-commit.md @@ -0,0 +1,5 @@ +--- +"@changesets/action": major +--- + +Default `commit-mode` to `github-api`. Set it to `git-cli` to retain Git CLI commits and tag pushes. diff --git a/README.md b/README.md index 1bad65de..dfc1f0d6 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ There are also sub-actions hosted in this repository. Check out their respective - pr-title - The pull request title. Default to `Version Packages` - create-github-releases - A boolean value to indicate whether to create Github releases after `publish` or not. Default to `true` - push-git-tags - A boolean value to indicate whether to create git tags after `publish` or not. Default to `true` -- commit-mode - Specifies the commit mode. Use `"git-cli"` to push changes using the Git CLI, or `"github-api"` to push changes via the GitHub API. When using `"github-api"`, all commits and tags are GPG-signed and attributed to the user or app who owns the `GITHUB_TOKEN`. Default to `git-cli` +- commit-mode - Specifies the commit mode. Use `"git-cli"` to push changes using the Git CLI, or `"github-api"` to push changes via the GitHub API. When using `"github-api"`, all commits and tags are GPG-signed and attributed to the user or app who owns the `GITHUB_TOKEN`. Default to `github-api` - cwd - Changes node's `process.cwd()` if the project is not located on the root. Default to `process.cwd()` - pr-draft - Controls draft PR behavior. Use `create` to create new version PRs as draft, or `always` to also convert existing version PRs back to draft when updating them. By default, version PRs are not forced into draft mode - github-token - Passes a custom GitHub token diff --git a/action.yml b/action.yml index b225c6f8..f139f42f 100644 --- a/action.yml +++ b/action.yml @@ -45,7 +45,7 @@ inputs: all commits and tags are signed using GitHub's GPG key and attributed to the user or app who owns the GITHUB_TOKEN. required: false - default: "git-cli" + default: "github-api" outputs: published: description: A boolean value to indicate whether a publishing is happened or not diff --git a/src/github.test.ts b/src/github.test.ts index bc54d4b5..c19c9358 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -26,6 +26,15 @@ beforeEach(() => { }); describe("GitHub", () => { + it("defaults to GitHub API mode", () => { + const github = new GitHub({ + cwd: "/repo", + githubToken: "token", + }); + + expect(github.commitMode).toBe("github-api"); + }); + it("clears inherited git auth headers before adding the github-token header", async () => { vi.mocked(getExecOutput) .mockResolvedValueOnce({ diff --git a/src/github.ts b/src/github.ts index b606aa6f..6a7f3fc5 100644 --- a/src/github.ts +++ b/src/github.ts @@ -82,7 +82,7 @@ export class GitHub { }) { this.#githubToken = options.githubToken; this.cwd = options.cwd; - this.commitMode = options.commitMode ?? "git-cli"; + this.commitMode = options.commitMode ?? "github-api"; this.octokit = setupOctokit(options.githubToken); } diff --git a/src/index.ts b/src/index.ts index d36485d7..8031344c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,7 @@ import { ); } - const commitMode = getOptionalInput("commit-mode") ?? "git-cli"; + const commitMode = getOptionalInput("commit-mode") ?? "github-api"; const prDraft = getOptionalInput("pr-draft"); if (commitMode !== "git-cli" && commitMode !== "github-api") { core.setFailed(`Invalid commit mode: ${commitMode}`); diff --git a/src/version/index.ts b/src/version/index.ts index edf82980..796beaae 100644 --- a/src/version/index.ts +++ b/src/version/index.ts @@ -24,7 +24,7 @@ async function main() { const prTitle = getRequiredInput("pr-title"); const prDraft = getOptionalInput("pr-draft"); const prBaseBranch = getOptionalInput("pr-base-branch"); - const commitMode = getOptionalInput("commit-mode") ?? "git-cli"; + const commitMode = getOptionalInput("commit-mode") ?? "github-api"; // Validations if (prDraft !== undefined && prDraft !== "always" && prDraft !== "create") { diff --git a/version/action.yml b/version/action.yml index 9ba675e1..ed93f740 100644 --- a/version/action.yml +++ b/version/action.yml @@ -32,7 +32,7 @@ inputs: all commits and tags are signed using GitHub's GPG key and attributed to the user or app who owns the GITHUB_TOKEN. required: false - default: "git-cli" + default: "github-api" outputs: pr-number: description: The pull request number that was created or updated From f860b5262852dfe30ec726bbac033b5870944e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 29 Jul 2026 11:08:58 +0200 Subject: [PATCH 04/24] e2e auth tests --- src/github.test.ts | 205 +++++++++++++++++++++++++++++++-------------- 1 file changed, 140 insertions(+), 65 deletions(-) diff --git a/src/github.test.ts b/src/github.test.ts index c19c9358..4493b658 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -1,27 +1,60 @@ import { Buffer } from "node:buffer"; -import { exec, getExecOutput } from "@actions/exec"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { createFixture } from "fs-fixture"; +import { exec } from "tinyexec"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GitHub } from "./github.ts"; +import { createGitHttpServer } from "./test-utils/gitHttpServer.ts"; -vi.mock("@actions/exec", () => ({ - exec: vi.fn(), - getExecOutput: vi.fn(), +const githubContext = vi.hoisted(() => ({ + repo: { + owner: "changesets", + repo: "action", + }, + serverUrl: "http://127.0.0.1", + sha: "base-sha", })); vi.mock("@actions/github", () => ({ - context: { - repo: { - owner: "changesets", - repo: "action", - }, - serverUrl: "https://github.com", - sha: "base-sha", - }, + context: githubContext, getOctokit: () => ({}), })); +async function git(cwd: string, args: string[]) { + const result = await exec("git", args, { + nodeOptions: { cwd }, + throwOnError: true, + }); + return result.stdout.trim(); +} + +async function initializeRepositories(root: string) { + const repository = path.join(root, "repository"); + const remote = path.join(root, "remote.git"); + + await git(repository, ["init", "-b", "main"]); + await git(repository, ["config", "user.name", "Test User"]); + await git(repository, ["config", "user.email", "test@example.com"]); + await git(repository, ["add", "."]); + await git(repository, ["commit", "-m", "Initial commit"]); + await git(root, ["clone", "--bare", repository, remote]); + await git(remote, ["config", "http.receivepack", "true"]); + + return { remote, repository }; +} + +function getAuthorization(token: string) { + return `basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`; +} + beforeEach(() => { - vi.clearAllMocks(); + vi.stubEnv("GIT_CONFIG_COUNT", "0"); + vi.stubEnv("GIT_CONFIG_NOSYSTEM", "1"); + vi.stubEnv("GIT_TERMINAL_PROMPT", "0"); +}); + +afterEach(() => { vi.unstubAllEnvs(); }); @@ -35,27 +68,36 @@ describe("GitHub", () => { expect(github.commitMode).toBe("github-api"); }); - 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"); + it("uses github-token instead of checkout's persisted header for CLI branch and tag pushes", async () => { + await using fixture = await createFixture({ + "global.gitconfig": "", + "repository/file.txt": "initial\n", + }); + vi.stubEnv( + "GIT_CONFIG_GLOBAL", + path.join(fixture.path, "global.gitconfig"), + ); + const { remote, repository } = await initializeRepositories(fixture.path); + const actionToken = "action-token"; + const checkoutToken = "checkout-token"; + + await using server = await createGitHttpServer({ + projectRoot: fixture.path, + expectedAuthorization: getAuthorization(actionToken), + }); + githubContext.serverUrl = server.origin; + const remoteUrl = `${server.origin}/remote.git`; + await git(repository, ["remote", "add", "origin", remoteUrl]); + await git(repository, [ + "config", + `http.${server.origin}/.extraheader`, + `AUTHORIZATION: ${getAuthorization(checkoutToken)}`, + ]); + await fs.writeFile(path.join(repository, "file.txt"), "changed\n"); const github = new GitHub({ - cwd: "/repo", - githubToken: "custom-token", + cwd: repository, + githubToken: actionToken, commitMode: "git-cli", }); @@ -63,39 +105,72 @@ describe("GitHub", () => { branch: "changeset-release/main", message: "Version Packages", }); + await git(repository, ["tag", "v1.0.0"]); + await github.pushTag("v1.0.0"); - expect(getExecOutput).toHaveBeenNthCalledWith( - 2, - "git", - ["remote", "get-url", "--push", "--all", "origin"], - { - cwd: "/repo", - ignoreReturnCode: true, - silent: true, - }, + expect( + await git(remote, ["rev-parse", "refs/heads/changeset-release/main"]), + ).toBe(await git(repository, ["rev-parse", "HEAD"])); + expect(await git(remote, ["rev-parse", "refs/tags/v1.0.0"])).toBe( + await git(repository, ["rev-parse", "v1.0.0"]), ); - 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")}`, - }), - }), + expect(server.receivedAuthorizationHeaders.length).toBeGreaterThan(0); + expect(server.receivedAuthorizationHeaders).toEqual( + server.receivedAuthorizationHeaders.map(() => [ + getAuthorization(actionToken), + ]), ); - }); + }, 15_000); + + it("uses github-token instead of credentials embedded in the CLI push URL", async () => { + await using fixture = await createFixture({ + "global.gitconfig": "", + "repository/file.txt": "initial\n", + }); + vi.stubEnv( + "GIT_CONFIG_GLOBAL", + path.join(fixture.path, "global.gitconfig"), + ); + const { remote, repository } = await initializeRepositories(fixture.path); + const actionToken = "action-token"; + + await using server = await createGitHttpServer({ + projectRoot: fixture.path, + expectedAuthorization: getAuthorization(actionToken), + }); + githubContext.serverUrl = server.origin; + const remoteUrl = new URL(`${server.origin}/remote.git`); + remoteUrl.username = "x-access-token"; + remoteUrl.password = "checkout-token"; + await git(repository, ["remote", "add", "origin", remoteUrl.href]); + const persistedCredentialUrl = new URL(remoteUrl); + persistedCredentialUrl.password = ""; + await git(repository, [ + "config", + `http.${persistedCredentialUrl.href}.extraheader`, + `AUTHORIZATION: ${getAuthorization("checkout-token")}`, + ]); + + await fs.writeFile(path.join(repository, "file.txt"), "changed\n"); + const github = new GitHub({ + cwd: repository, + githubToken: actionToken, + commitMode: "git-cli", + }); + + await github.pushChanges({ + branch: "changeset-release/main", + message: "Version Packages", + }); + + expect( + await git(remote, ["rev-parse", "refs/heads/changeset-release/main"]), + ).toBe(await git(repository, ["rev-parse", "HEAD"])); + expect(server.receivedAuthorizationHeaders.length).toBeGreaterThan(0); + expect(server.receivedAuthorizationHeaders).toEqual( + server.receivedAuthorizationHeaders.map(() => [ + getAuthorization(actionToken), + ]), + ); + }, 15_000); }); From b81c97b2e06a9f89135691423a77e39867f21a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 29 Jul 2026 11:27:51 +0200 Subject: [PATCH 05/24] push missing utils --- src/test-utils/gitHttpServer.ts | 138 ++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/test-utils/gitHttpServer.ts diff --git a/src/test-utils/gitHttpServer.ts b/src/test-utils/gitHttpServer.ts new file mode 100644 index 00000000..ede3929c --- /dev/null +++ b/src/test-utils/gitHttpServer.ts @@ -0,0 +1,138 @@ +import { spawn } from "node:child_process"; +import http, { type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; + +function getAuthorizationHeaders(request: IncomingMessage): string[] { + const values: string[] = []; + for (let index = 0; index < request.rawHeaders.length; index += 2) { + if (request.rawHeaders[index]?.toLowerCase() === "authorization") { + values.push(request.rawHeaders[index + 1] ?? ""); + } + } + return values; +} + +async function runGitHttpBackend( + request: IncomingMessage, + response: ServerResponse, + projectRoot: string, +) { + 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: projectRoot, + 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((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)); +} + +export async function createGitHttpServer(options: { + projectRoot: string; + expectedAuthorization: string; +}) { + const receivedAuthorizationHeaders: string[][] = []; + const server = http.createServer((request, response) => { + const authorizationHeaders = getAuthorizationHeaders(request); + receivedAuthorizationHeaders.push(authorizationHeaders); + + if ( + authorizationHeaders.length !== 1 || + authorizationHeaders[0] !== options.expectedAuthorization + ) { + response.writeHead(401, { + "WWW-Authenticate": 'Basic realm="changesets-action-test"', + }); + response.end(); + return; + } + + void runGitHttpBackend(request, response, options.projectRoot).catch( + (error: unknown) => { + response.destroy( + error instanceof Error ? error : new Error(String(error)), + ); + }, + ); + }); + + await new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + + return { + origin: `http://127.0.0.1:${address.port}`, + receivedAuthorizationHeaders, + async [Symbol.asyncDispose]() { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + }, + }; +} From 2ef83c7d7349676c89c7dcb1a7f9f573fcd9edb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 29 Jul 2026 11:37:32 +0200 Subject: [PATCH 06/24] rename input --- .changeset/brave-pandas-commit.md | 2 +- README.md | 2 +- action.yml | 9 +++------ src/github.test.ts | 6 +++--- src/github.ts | 14 ++++++-------- src/index.ts | 10 +++------- src/publish/index.ts | 4 ++-- src/run.test.ts | 2 +- src/version/index.ts | 8 ++------ version/action.yml | 9 +++------ 10 files changed, 25 insertions(+), 41 deletions(-) diff --git a/.changeset/brave-pandas-commit.md b/.changeset/brave-pandas-commit.md index b4ce181f..2033a599 100644 --- a/.changeset/brave-pandas-commit.md +++ b/.changeset/brave-pandas-commit.md @@ -2,4 +2,4 @@ "@changesets/action": major --- -Default `commit-mode` to `github-api`. Set it to `git-cli` to retain Git CLI commits and tag pushes. +Replace `commit-mode` with the boolean `push-with-git-cli` input. GitHub API pushes are used by default; set `push-with-git-cli` to `true` to push release commits and tags with the Git CLI. diff --git a/README.md b/README.md index dfc1f0d6..06756c82 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ There are also sub-actions hosted in this repository. Check out their respective - pr-title - The pull request title. Default to `Version Packages` - create-github-releases - A boolean value to indicate whether to create Github releases after `publish` or not. Default to `true` - push-git-tags - A boolean value to indicate whether to create git tags after `publish` or not. Default to `true` -- commit-mode - Specifies the commit mode. Use `"git-cli"` to push changes using the Git CLI, or `"github-api"` to push changes via the GitHub API. When using `"github-api"`, all commits and tags are GPG-signed and attributed to the user or app who owns the `GITHUB_TOKEN`. Default to `github-api` +- push-with-git-cli - Whether to use the Git CLI instead of the GitHub API to push release commits and tags. Default to `false` - cwd - Changes node's `process.cwd()` if the project is not located on the root. Default to `process.cwd()` - pr-draft - Controls draft PR behavior. Use `create` to create new version PRs as draft, or `always` to also convert existing version PRs back to draft when updating them. By default, version PRs are not forced into draft mode - github-token - Passes a custom GitHub token diff --git a/action.yml b/action.yml index f139f42f..47496c7a 100644 --- a/action.yml +++ b/action.yml @@ -38,14 +38,11 @@ inputs: this option will also always be `true`. required: false default: true - commit-mode: + push-with-git-cli: description: > - An enum to specify the commit mode. Use "git-cli" to push changes using the Git CLI, - or "github-api" to push changes via the GitHub API. When using "github-api", - all commits and tags are signed using GitHub's GPG key and attributed to the user - or app who owns the GITHUB_TOKEN. + Whether to use the Git CLI instead of the GitHub API to push release commits and tags. required: false - default: "github-api" + default: false outputs: published: description: A boolean value to indicate whether a publishing is happened or not diff --git a/src/github.test.ts b/src/github.test.ts index 4493b658..9f90366a 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -65,7 +65,7 @@ describe("GitHub", () => { githubToken: "token", }); - expect(github.commitMode).toBe("github-api"); + expect(github.pushWithGitCli).toBe(false); }); it("uses github-token instead of checkout's persisted header for CLI branch and tag pushes", async () => { @@ -98,7 +98,7 @@ describe("GitHub", () => { const github = new GitHub({ cwd: repository, githubToken: actionToken, - commitMode: "git-cli", + pushWithGitCli: true, }); await github.pushChanges({ @@ -155,7 +155,7 @@ describe("GitHub", () => { const github = new GitHub({ cwd: repository, githubToken: actionToken, - commitMode: "git-cli", + pushWithGitCli: true, }); await github.pushChanges({ diff --git a/src/github.ts b/src/github.ts index 6a7f3fc5..f396b43d 100644 --- a/src/github.ts +++ b/src/github.ts @@ -5,8 +5,6 @@ import { context } from "@actions/github"; import { commitChangesSinceBase } from "@changesets/ghcommit"; import { setupOctokit, type Octokit } from "./octokit.ts"; -export type CommitMode = "git-cli" | "github-api"; - type GitOptions = { cwd: string; env?: Record; @@ -73,16 +71,16 @@ export class GitHub { readonly #githubToken: string; readonly octokit: Octokit; readonly cwd: string; - readonly commitMode: CommitMode; + readonly pushWithGitCli: boolean; constructor(options: { githubToken: string; cwd: string; - commitMode?: CommitMode; + pushWithGitCli?: boolean; }) { this.#githubToken = options.githubToken; this.cwd = options.cwd; - this.commitMode = options.commitMode ?? "github-api"; + this.pushWithGitCli = options.pushWithGitCli ?? false; this.octokit = setupOctokit(options.githubToken); } @@ -204,7 +202,7 @@ export class GitHub { } async pushTag(tag: string) { - if (this.commitMode === "github-api") { + if (!this.pushWithGitCli) { return this.octokit.rest.git .createRef({ ...context.repo, @@ -226,7 +224,7 @@ export class GitHub { } async prepareBranch(branch: string) { - if (this.commitMode === "github-api") { + if (!this.pushWithGitCli) { // Preparing a new local branch is not necessary when using the API return; } @@ -235,7 +233,7 @@ export class GitHub { } async pushChanges({ branch, message }: { branch: string; message: string }) { - if (this.commitMode === "github-api") { + if (!this.pushWithGitCli) { await commitChangesSinceBase({ octokit: this.octokit, ...context.repo, diff --git a/src/index.ts b/src/index.ts index 8031344c..a9cfc535 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,7 @@ import { branch: "pr-base-branch", prDraft: "pr-draft", createGithubReleases: "create-github-releases", - commitMode: "commit-mode", + commitMode: "push-with-git-cli", }); const githubToken = getRequiredInput("github-token"); @@ -34,12 +34,8 @@ import { ); } - const commitMode = getOptionalInput("commit-mode") ?? "github-api"; + const pushWithGitCli = core.getBooleanInput("push-with-git-cli"); const prDraft = getOptionalInput("pr-draft"); - if (commitMode !== "git-cli" && commitMode !== "github-api") { - core.setFailed(`Invalid commit mode: ${commitMode}`); - return; - } if (prDraft !== undefined && prDraft !== "always" && prDraft !== "create") { core.setFailed(`Invalid pr-draft: ${prDraft}`); return; @@ -47,7 +43,7 @@ import { const github = new GitHub({ cwd, githubToken, - commitMode, + pushWithGitCli, }); let { changesets } = await readChangesetState(cwd); diff --git a/src/publish/index.ts b/src/publish/index.ts index 925489a2..308c93c8 100644 --- a/src/publish/index.ts +++ b/src/publish/index.ts @@ -35,8 +35,8 @@ async function main() { ); } - // NOTE: Always use API mode here as publish does not need a commit-mode. - const github = new GitHub({ cwd, githubToken, commitMode: "github-api" }); + // The publish sub-action always uses the GitHub API for tag pushes. + const github = new GitHub({ cwd, githubToken }); const fromPackDir = packDirArtifactId ? await downloadArtifact( diff --git a/src/run.test.ts b/src/run.test.ts index d6e8cbd4..8f3f4eb0 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -99,7 +99,7 @@ const createGithub = (cwd: string) => new GitHub({ cwd, githubToken: "@@GITHUB_TOKEN", - commitMode: "github-api", + pushWithGitCli: false, }); async function initializeGitRepository(cwd: string) { diff --git a/src/version/index.ts b/src/version/index.ts index 796beaae..4768d5fb 100644 --- a/src/version/index.ts +++ b/src/version/index.ts @@ -24,20 +24,16 @@ async function main() { const prTitle = getRequiredInput("pr-title"); const prDraft = getOptionalInput("pr-draft"); const prBaseBranch = getOptionalInput("pr-base-branch"); - const commitMode = getOptionalInput("commit-mode") ?? "github-api"; + const pushWithGitCli = core.getBooleanInput("push-with-git-cli"); // Validations if (prDraft !== undefined && prDraft !== "always" && prDraft !== "create") { throw new Error(`Invalid pr-draft input: ${prDraft}`); } - if (commitMode !== "git-cli" && commitMode !== "github-api") { - throw new Error(`Invalid commit-mode input: ${commitMode}`); - } - const github = new GitHub({ cwd, githubToken, - commitMode, + pushWithGitCli, }); const { pullRequestNumber } = await runVersion({ diff --git a/version/action.yml b/version/action.yml index ed93f740..68e472cb 100644 --- a/version/action.yml +++ b/version/action.yml @@ -25,14 +25,11 @@ inputs: pr-base-branch: description: "Sets the base branch of the PR. Defaults to `github.ref_name`." required: false - commit-mode: + push-with-git-cli: description: > - An enum to specify the commit mode. Use "git-cli" to push changes using the Git CLI, - or "github-api" to push changes via the GitHub API. When using "github-api", - all commits and tags are signed using GitHub's GPG key and attributed to the user - or app who owns the GITHUB_TOKEN. + Whether to use the Git CLI instead of the GitHub API to push release commits. required: false - default: "github-api" + default: false outputs: pr-number: description: The pull request number that was created or updated From 6b3e3a77fe7891aa451377bc2e33d70a7a044051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 29 Jul 2026 11:44:41 +0200 Subject: [PATCH 07/24] refactor --- src/github.test.ts | 20 ++++++++------------ src/test-utils/gitHttpServer.ts | 31 +++++++++++++++++++++---------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/github.test.ts b/src/github.test.ts index 9f90366a..20d26d6c 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -114,12 +114,10 @@ describe("GitHub", () => { expect(await git(remote, ["rev-parse", "refs/tags/v1.0.0"])).toBe( await git(repository, ["rev-parse", "v1.0.0"]), ); - expect(server.receivedAuthorizationHeaders.length).toBeGreaterThan(0); - expect(server.receivedAuthorizationHeaders).toEqual( - server.receivedAuthorizationHeaders.map(() => [ - getAuthorization(actionToken), - ]), - ); + expect(server.requests.length).toBeGreaterThan(0); + expect( + server.requests.map((request) => request.headers.authorization), + ).toEqual(server.requests.map(() => [getAuthorization(actionToken)])); }, 15_000); it("uses github-token instead of credentials embedded in the CLI push URL", async () => { @@ -166,11 +164,9 @@ describe("GitHub", () => { expect( await git(remote, ["rev-parse", "refs/heads/changeset-release/main"]), ).toBe(await git(repository, ["rev-parse", "HEAD"])); - expect(server.receivedAuthorizationHeaders.length).toBeGreaterThan(0); - expect(server.receivedAuthorizationHeaders).toEqual( - server.receivedAuthorizationHeaders.map(() => [ - getAuthorization(actionToken), - ]), - ); + expect(server.requests.length).toBeGreaterThan(0); + expect( + server.requests.map((request) => request.headers.authorization), + ).toEqual(server.requests.map(() => [getAuthorization(actionToken)])); }, 15_000); }); diff --git a/src/test-utils/gitHttpServer.ts b/src/test-utils/gitHttpServer.ts index ede3929c..5f445d22 100644 --- a/src/test-utils/gitHttpServer.ts +++ b/src/test-utils/gitHttpServer.ts @@ -2,14 +2,24 @@ import { spawn } from "node:child_process"; import http, { type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; -function getAuthorizationHeaders(request: IncomingMessage): string[] { - const values: string[] = []; +type RecordedRequest = { + method: string; + url: string; + headers: Record; +}; + +function recordRequest(request: IncomingMessage): RecordedRequest { + const headers: Record = {}; for (let index = 0; index < request.rawHeaders.length; index += 2) { - if (request.rawHeaders[index]?.toLowerCase() === "authorization") { - values.push(request.rawHeaders[index + 1] ?? ""); - } + const name = request.rawHeaders[index]?.toLowerCase(); + if (name === undefined) continue; + (headers[name] ??= []).push(request.rawHeaders[index + 1] ?? ""); } - return values; + return { + method: request.method ?? "GET", + url: request.url ?? "/", + headers, + }; } async function runGitHttpBackend( @@ -92,10 +102,11 @@ export async function createGitHttpServer(options: { projectRoot: string; expectedAuthorization: string; }) { - const receivedAuthorizationHeaders: string[][] = []; + const requests: RecordedRequest[] = []; const server = http.createServer((request, response) => { - const authorizationHeaders = getAuthorizationHeaders(request); - receivedAuthorizationHeaders.push(authorizationHeaders); + const recordedRequest = recordRequest(request); + requests.push(recordedRequest); + const authorizationHeaders = recordedRequest.headers.authorization ?? []; if ( authorizationHeaders.length !== 1 || @@ -125,7 +136,7 @@ export async function createGitHttpServer(options: { return { origin: `http://127.0.0.1:${address.port}`, - receivedAuthorizationHeaders, + requests, async [Symbol.asyncDispose]() { await new Promise((resolve, reject) => { server.close((error) => { From 60ca9a9945a415dc7bfaa62f1316943d439f47ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Thu, 30 Jul 2026 00:12:31 +0200 Subject: [PATCH 08/24] refactor --- src/github.test.ts | 54 ++++++------------- src/test-utils/gitHttpServer.ts | 37 ++++++++++--- src/test-utils/index.ts | 96 +++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 46 deletions(-) create mode 100644 src/test-utils/index.ts diff --git a/src/github.test.ts b/src/github.test.ts index 20d26d6c..bed65cbf 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -1,11 +1,11 @@ import { Buffer } from "node:buffer"; import fs from "node:fs/promises"; import path from "node:path"; -import { createFixture } from "fs-fixture"; import { exec } from "tinyexec"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GitHub } from "./github.ts"; import { createGitHttpServer } from "./test-utils/gitHttpServer.ts"; +import { createLocalRemote, gitdir, testdir } from "./test-utils/index.ts"; const githubContext = vi.hoisted(() => ({ repo: { @@ -29,25 +29,15 @@ async function git(cwd: string, args: string[]) { return result.stdout.trim(); } -async function initializeRepositories(root: string) { - const repository = path.join(root, "repository"); - const remote = path.join(root, "remote.git"); - - await git(repository, ["init", "-b", "main"]); - await git(repository, ["config", "user.name", "Test User"]); - await git(repository, ["config", "user.email", "test@example.com"]); - await git(repository, ["add", "."]); - await git(repository, ["commit", "-m", "Initial commit"]); - await git(root, ["clone", "--bare", repository, remote]); - await git(remote, ["config", "http.receivepack", "true"]); - - return { remote, repository }; -} - function getAuthorization(token: string) { return `basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`; } +async function isolateGitConfig() { + const configDir = await testdir({ "global.gitconfig": "" }); + vi.stubEnv("GIT_CONFIG_GLOBAL", path.join(configDir, "global.gitconfig")); +} + beforeEach(() => { vi.stubEnv("GIT_CONFIG_COUNT", "0"); vi.stubEnv("GIT_CONFIG_NOSYSTEM", "1"); @@ -69,24 +59,18 @@ describe("GitHub", () => { }); it("uses github-token instead of checkout's persisted header for CLI branch and tag pushes", async () => { - await using fixture = await createFixture({ - "global.gitconfig": "", - "repository/file.txt": "initial\n", - }); - vi.stubEnv( - "GIT_CONFIG_GLOBAL", - path.join(fixture.path, "global.gitconfig"), - ); - const { remote, repository } = await initializeRepositories(fixture.path); + await isolateGitConfig(); + const repository = await gitdir({ "file.txt": "initial\n" }); + const remote = await createLocalRemote(repository); const actionToken = "action-token"; const checkoutToken = "checkout-token"; await using server = await createGitHttpServer({ - projectRoot: fixture.path, + projectRoot: path.dirname(remote), expectedAuthorization: getAuthorization(actionToken), }); githubContext.serverUrl = server.origin; - const remoteUrl = `${server.origin}/remote.git`; + const remoteUrl = `${server.origin}/${path.basename(remote)}`; await git(repository, ["remote", "add", "origin", remoteUrl]); await git(repository, [ "config", @@ -121,23 +105,17 @@ describe("GitHub", () => { }, 15_000); it("uses github-token instead of credentials embedded in the CLI push URL", async () => { - await using fixture = await createFixture({ - "global.gitconfig": "", - "repository/file.txt": "initial\n", - }); - vi.stubEnv( - "GIT_CONFIG_GLOBAL", - path.join(fixture.path, "global.gitconfig"), - ); - const { remote, repository } = await initializeRepositories(fixture.path); + await isolateGitConfig(); + const repository = await gitdir({ "file.txt": "initial\n" }); + const remote = await createLocalRemote(repository); const actionToken = "action-token"; await using server = await createGitHttpServer({ - projectRoot: fixture.path, + projectRoot: path.dirname(remote), expectedAuthorization: getAuthorization(actionToken), }); githubContext.serverUrl = server.origin; - const remoteUrl = new URL(`${server.origin}/remote.git`); + const remoteUrl = new URL(`${server.origin}/${path.basename(remote)}`); remoteUrl.username = "x-access-token"; remoteUrl.password = "checkout-token"; await git(repository, ["remote", "add", "origin", remoteUrl.href]); diff --git a/src/test-utils/gitHttpServer.ts b/src/test-utils/gitHttpServer.ts index 5f445d22..44a18ea0 100644 --- a/src/test-utils/gitHttpServer.ts +++ b/src/test-utils/gitHttpServer.ts @@ -1,6 +1,6 @@ +import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import http, { type IncomingMessage, type ServerResponse } from "node:http"; -import type { AddressInfo } from "node:net"; type RecordedRequest = { method: string; @@ -98,6 +98,23 @@ async function runGitHttpBackend( 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); + } +} + export async function createGitHttpServer(options: { projectRoot: string; expectedAuthorization: string; @@ -128,11 +145,12 @@ export async function createGitHttpServer(options: { ); }); - await new Promise((resolve, reject) => { - server.on("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const address = server.address() as AddressInfo; + 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}`, @@ -140,8 +158,11 @@ export async function createGitHttpServer(options: { async [Symbol.asyncDispose]() { await new Promise((resolve, reject) => { server.close((error) => { - if (error) reject(error); - else resolve(); + if (error) { + reject(error); + return; + } + resolve(); }); }); }, diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts new file mode 100644 index 00000000..769f5fff --- /dev/null +++ b/src/test-utils/index.ts @@ -0,0 +1,96 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createFixture, type FileTree } from "fs-fixture"; +import { exec } from "tinyexec"; +import { onTestFinished } from "vitest"; + +export type Fixture = FileTree; + +export async function testdir(dir?: Fixture) { + const fixture = await createFixture(dir, { + fs: { + ...fsp, + rm: (filePath, options) => + fsp.rm(filePath, { + maxRetries: 3, + retryDelay: 100, + ...options, + }), + }, + }); + onTestFinished(() => fixture.rm()); + return fixture.path; +} + +// 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) { + const cwd = await testdir({ + ".gitattributes": "* text=auto eol=lf\n", + ...dir, + }); + + 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 cwd; +} + +export async function createLocalRemote(cwd: string) { + const remote = await testdir(); + await exec("git", ["clone", "--bare", pathToFileURL(cwd).toString(), "."], { + nodeOptions: { cwd: remote }, + throwOnError: true, + }); + await disableGitBackgroundMaintenance(remote); + await exec("git", ["config", "http.receivepack", "true"], { + nodeOptions: { cwd: remote }, + throwOnError: true, + }); + return remote; +} From 811eb9ad3d50f9f67cea4ebedec0d99703656756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Thu, 30 Jul 2026 00:25:07 +0200 Subject: [PATCH 09/24] refactor --- src/github.test.ts | 21 +++++++++++++-------- src/pr-status/worktree.ts | 17 +---------------- src/test-utils/gitHttpServer.ts | 4 +++- src/test-utils/index.ts | 26 +++++++++++++++----------- src/utils.ts | 16 ++++++++++++++++ 5 files changed, 48 insertions(+), 36 deletions(-) diff --git a/src/github.test.ts b/src/github.test.ts index bed65cbf..7de098a0 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -34,8 +34,9 @@ function getAuthorization(token: string) { } async function isolateGitConfig() { - const configDir = await testdir({ "global.gitconfig": "" }); - vi.stubEnv("GIT_CONFIG_GLOBAL", path.join(configDir, "global.gitconfig")); + const fixture = await testdir({ "global.gitconfig": "" }); + vi.stubEnv("GIT_CONFIG_GLOBAL", path.join(fixture.path, "global.gitconfig")); + return fixture; } beforeEach(() => { @@ -59,9 +60,11 @@ describe("GitHub", () => { }); it("uses github-token instead of checkout's persisted header for CLI branch and tag pushes", async () => { - await isolateGitConfig(); - const repository = await gitdir({ "file.txt": "initial\n" }); - const remote = await createLocalRemote(repository); + await using _gitConfig = await isolateGitConfig(); + await using repositoryFixture = await gitdir({ "file.txt": "initial\n" }); + const repository = repositoryFixture.path; + await using remoteFixture = await createLocalRemote(repository); + const remote = remoteFixture.path; const actionToken = "action-token"; const checkoutToken = "checkout-token"; @@ -105,9 +108,11 @@ describe("GitHub", () => { }, 15_000); it("uses github-token instead of credentials embedded in the CLI push URL", async () => { - await isolateGitConfig(); - const repository = await gitdir({ "file.txt": "initial\n" }); - const remote = await createLocalRemote(repository); + await using _gitConfig = await isolateGitConfig(); + await using repositoryFixture = await gitdir({ "file.txt": "initial\n" }); + const repository = repositoryFixture.path; + await using remoteFixture = await createLocalRemote(repository); + const remote = remoteFixture.path; const actionToken = "action-token"; await using server = await createGitHttpServer({ diff --git a/src/pr-status/worktree.ts b/src/pr-status/worktree.ts index 08aeae02..a3a777de 100644 --- a/src/pr-status/worktree.ts +++ b/src/pr-status/worktree.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type * as github from "@actions/github"; import { isRepoShallow } from "@changesets/git"; import { exec } from "tinyexec"; +import { moveDisposable, type WithAsyncDispose } from "../utils.ts"; type PullRequestContext = NonNullable< typeof github.context.payload.pull_request @@ -122,22 +123,6 @@ async function tempWorktree(cwd: string, dir: string, ref: Ref) { }; } -type WithAsyncDispose = T & { - [Symbol.asyncDispose](): Promise; -}; - -function moveDisposable( - stack: AsyncDisposableStack, - value: T, -): WithAsyncDispose { - const moved = stack.move(); - return Object.assign(value, { - async [Symbol.asyncDispose]() { - await moved.disposeAsync(); - }, - }); -} - export async function getPullRequestWorktree( context: PullRequestContext, cwd: string = process.cwd(), diff --git a/src/test-utils/gitHttpServer.ts b/src/test-utils/gitHttpServer.ts index 44a18ea0..732c8461 100644 --- a/src/test-utils/gitHttpServer.ts +++ b/src/test-utils/gitHttpServer.ts @@ -139,7 +139,9 @@ export async function createGitHttpServer(options: { void runGitHttpBackend(request, response, options.projectRoot).catch( (error: unknown) => { response.destroy( - error instanceof Error ? error : new Error(String(error)), + Error.isError(error) + ? error + : new Error("Server error", { cause: error }), ); }, ); diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts index 769f5fff..ee0713e7 100644 --- a/src/test-utils/index.ts +++ b/src/test-utils/index.ts @@ -3,12 +3,12 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { createFixture, type FileTree } from "fs-fixture"; import { exec } from "tinyexec"; -import { onTestFinished } from "vitest"; +import { moveDisposable } from "../utils.ts"; export type Fixture = FileTree; export async function testdir(dir?: Fixture) { - const fixture = await createFixture(dir, { + return createFixture(dir, { fs: { ...fsp, rm: (filePath, options) => @@ -19,8 +19,6 @@ export async function testdir(dir?: Fixture) { }), }, }); - onTestFinished(() => fixture.rm()); - return fixture.path; } // Git maintenance can race with fixture cleanup by touching pack files. @@ -36,10 +34,14 @@ export async function disableGitBackgroundMaintenance(cwd: string) { } export async function gitdir(dir: Fixture) { - const cwd = await testdir({ - ".gitattributes": "* text=auto eol=lf\n", - ...dir, - }); + 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 }, @@ -78,11 +80,13 @@ export async function gitdir(dir: Fixture) { throwOnError: true, }); - return cwd; + return moveDisposable(stack, fixture); } export async function createLocalRemote(cwd: string) { - const remote = await testdir(); + await using stack = new AsyncDisposableStack(); + const fixture = stack.use(await testdir()); + const remote = fixture.path; await exec("git", ["clone", "--bare", pathToFileURL(cwd).toString(), "."], { nodeOptions: { cwd: remote }, throwOnError: true, @@ -92,5 +96,5 @@ export async function createLocalRemote(cwd: string) { nodeOptions: { cwd: remote }, throwOnError: true, }); - return remote; + return moveDisposable(stack, fixture); } diff --git a/src/utils.ts b/src/utils.ts index dfcda7d3..f5349b23 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -119,6 +119,22 @@ export function isErrorWithCode(err: unknown, code: string) { ); } +export type WithAsyncDispose = T & { + [Symbol.asyncDispose](): Promise; +}; + +export function moveDisposable( + stack: AsyncDisposableStack, + value: T, +): WithAsyncDispose { + const moved = stack.move(); + return Object.assign(value, { + async [Symbol.asyncDispose]() { + await moved.disposeAsync(); + }, + }); +} + export function getOptionalInput(name: string) { // normalize empty string default return value of `core.getInput` to undefined return core.getInput(name) || undefined; From eb6788af65cf54aa1d6a12c28d32c515b93f98b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Thu, 30 Jul 2026 00:39:49 +0200 Subject: [PATCH 10/24] refactor --- src/github.test.ts | 26 +++++++++++++++++--------- src/test-utils/index.ts | 34 +++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/github.test.ts b/src/github.test.ts index 7de098a0..6a1ebc6e 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -5,7 +5,11 @@ import { exec } from "tinyexec"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GitHub } from "./github.ts"; import { createGitHttpServer } from "./test-utils/gitHttpServer.ts"; -import { createLocalRemote, gitdir, testdir } from "./test-utils/index.ts"; +import { + createLocalRemote, + shallowClone, + testdir, +} from "./test-utils/index.ts"; const githubContext = vi.hoisted(() => ({ repo: { @@ -61,10 +65,12 @@ describe("GitHub", () => { it("uses github-token instead of checkout's persisted header for CLI branch and tag pushes", async () => { await using _gitConfig = await isolateGitConfig(); - await using repositoryFixture = await gitdir({ "file.txt": "initial\n" }); - const repository = repositoryFixture.path; - await using remoteFixture = await createLocalRemote(repository); + await using remoteFixture = await createLocalRemote({ + "file.txt": "initial\n", + }); const remote = remoteFixture.path; + await using repositoryFixture = await shallowClone(remote); + const repository = repositoryFixture.path; const actionToken = "action-token"; const checkoutToken = "checkout-token"; @@ -74,7 +80,7 @@ describe("GitHub", () => { }); githubContext.serverUrl = server.origin; const remoteUrl = `${server.origin}/${path.basename(remote)}`; - await git(repository, ["remote", "add", "origin", remoteUrl]); + await git(repository, ["remote", "set-url", "origin", remoteUrl]); await git(repository, [ "config", `http.${server.origin}/.extraheader`, @@ -109,10 +115,12 @@ describe("GitHub", () => { it("uses github-token instead of credentials embedded in the CLI push URL", async () => { await using _gitConfig = await isolateGitConfig(); - await using repositoryFixture = await gitdir({ "file.txt": "initial\n" }); - const repository = repositoryFixture.path; - await using remoteFixture = await createLocalRemote(repository); + await using remoteFixture = await createLocalRemote({ + "file.txt": "initial\n", + }); const remote = remoteFixture.path; + await using repositoryFixture = await shallowClone(remote); + const repository = repositoryFixture.path; const actionToken = "action-token"; await using server = await createGitHttpServer({ @@ -123,7 +131,7 @@ describe("GitHub", () => { const remoteUrl = new URL(`${server.origin}/${path.basename(remote)}`); remoteUrl.username = "x-access-token"; remoteUrl.password = "checkout-token"; - await git(repository, ["remote", "add", "origin", remoteUrl.href]); + await git(repository, ["remote", "set-url", "origin", remoteUrl.href]); const persistedCredentialUrl = new URL(remoteUrl); persistedCredentialUrl.password = ""; await git(repository, [ diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts index ee0713e7..85225f31 100644 --- a/src/test-utils/index.ts +++ b/src/test-utils/index.ts @@ -83,14 +83,23 @@ export async function gitdir(dir: Fixture) { return moveDisposable(stack, fixture); } -export async function createLocalRemote(cwd: string) { +export async function createLocalRemote(dir: Fixture) { await using stack = new AsyncDisposableStack(); const fixture = stack.use(await testdir()); const remote = fixture.path; - await exec("git", ["clone", "--bare", pathToFileURL(cwd).toString(), "."], { - nodeOptions: { cwd: remote }, - throwOnError: true, - }); + { + // 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 }, @@ -98,3 +107,18 @@ export async function createLocalRemote(cwd: string) { }); 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); +} From 274708c7d46df383dbea9ec82050449fe26393ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Thu, 30 Jul 2026 00:44:18 +0200 Subject: [PATCH 11/24] refactor --- src/github.test.ts | 67 ++++++++++++++++++++--------------------- src/test-utils/index.ts | 23 +++++++++++++- 2 files changed, 54 insertions(+), 36 deletions(-) diff --git a/src/github.test.ts b/src/github.test.ts index 6a1ebc6e..381c93fa 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -4,9 +4,8 @@ import path from "node:path"; import { exec } from "tinyexec"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GitHub } from "./github.ts"; -import { createGitHttpServer } from "./test-utils/gitHttpServer.ts"; import { - createLocalRemote, + createGitHttpRemote, shallowClone, testdir, } from "./test-utils/index.ts"; @@ -65,25 +64,21 @@ describe("GitHub", () => { it("uses github-token instead of checkout's persisted header for CLI branch and tag pushes", async () => { await using _gitConfig = await isolateGitConfig(); - await using remoteFixture = await createLocalRemote({ - "file.txt": "initial\n", - }); - const remote = remoteFixture.path; - await using repositoryFixture = await shallowClone(remote); - const repository = repositoryFixture.path; const actionToken = "action-token"; const checkoutToken = "checkout-token"; - - await using server = await createGitHttpServer({ - projectRoot: path.dirname(remote), + await using remote = await createGitHttpRemote({ + files: { "file.txt": "initial\n" }, expectedAuthorization: getAuthorization(actionToken), }); - githubContext.serverUrl = server.origin; - const remoteUrl = `${server.origin}/${path.basename(remote)}`; - await git(repository, ["remote", "set-url", "origin", remoteUrl]); + await using repositoryFixture = await shallowClone(remote.path); + const repository = repositoryFixture.path; + + const serverUrl = new URL(remote.url).origin; + githubContext.serverUrl = serverUrl; + await git(repository, ["remote", "set-url", "origin", remote.url]); await git(repository, [ "config", - `http.${server.origin}/.extraheader`, + `http.${serverUrl}/.extraheader`, `AUTHORIZATION: ${getAuthorization(checkoutToken)}`, ]); @@ -102,33 +97,32 @@ describe("GitHub", () => { await github.pushTag("v1.0.0"); expect( - await git(remote, ["rev-parse", "refs/heads/changeset-release/main"]), + await git(remote.path, [ + "rev-parse", + "refs/heads/changeset-release/main", + ]), ).toBe(await git(repository, ["rev-parse", "HEAD"])); - expect(await git(remote, ["rev-parse", "refs/tags/v1.0.0"])).toBe( + expect(await git(remote.path, ["rev-parse", "refs/tags/v1.0.0"])).toBe( await git(repository, ["rev-parse", "v1.0.0"]), ); - expect(server.requests.length).toBeGreaterThan(0); + expect(remote.requests.length).toBeGreaterThan(0); expect( - server.requests.map((request) => request.headers.authorization), - ).toEqual(server.requests.map(() => [getAuthorization(actionToken)])); + remote.requests.map((request) => request.headers.authorization), + ).toEqual(remote.requests.map(() => [getAuthorization(actionToken)])); }, 15_000); it("uses github-token instead of credentials embedded in the CLI push URL", async () => { await using _gitConfig = await isolateGitConfig(); - await using remoteFixture = await createLocalRemote({ - "file.txt": "initial\n", - }); - const remote = remoteFixture.path; - await using repositoryFixture = await shallowClone(remote); - const repository = repositoryFixture.path; const actionToken = "action-token"; - - await using server = await createGitHttpServer({ - projectRoot: path.dirname(remote), + await using remote = await createGitHttpRemote({ + files: { "file.txt": "initial\n" }, expectedAuthorization: getAuthorization(actionToken), }); - githubContext.serverUrl = server.origin; - const remoteUrl = new URL(`${server.origin}/${path.basename(remote)}`); + await using repositoryFixture = await shallowClone(remote.path); + const repository = repositoryFixture.path; + + const remoteUrl = new URL(remote.url); + githubContext.serverUrl = remoteUrl.origin; remoteUrl.username = "x-access-token"; remoteUrl.password = "checkout-token"; await git(repository, ["remote", "set-url", "origin", remoteUrl.href]); @@ -153,11 +147,14 @@ describe("GitHub", () => { }); expect( - await git(remote, ["rev-parse", "refs/heads/changeset-release/main"]), + await git(remote.path, [ + "rev-parse", + "refs/heads/changeset-release/main", + ]), ).toBe(await git(repository, ["rev-parse", "HEAD"])); - expect(server.requests.length).toBeGreaterThan(0); + expect(remote.requests.length).toBeGreaterThan(0); expect( - server.requests.map((request) => request.headers.authorization), - ).toEqual(server.requests.map(() => [getAuthorization(actionToken)])); + remote.requests.map((request) => request.headers.authorization), + ).toEqual(remote.requests.map(() => [getAuthorization(actionToken)])); }, 15_000); }); diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts index 85225f31..1de2dc13 100644 --- a/src/test-utils/index.ts +++ b/src/test-utils/index.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url"; import { createFixture, type FileTree } from "fs-fixture"; import { exec } from "tinyexec"; import { moveDisposable } from "../utils.ts"; +import { createGitHttpServer } from "./gitHttpServer.ts"; export type Fixture = FileTree; @@ -83,7 +84,7 @@ export async function gitdir(dir: Fixture) { return moveDisposable(stack, fixture); } -export async function createLocalRemote(dir: Fixture) { +async function createLocalRemote(dir: Fixture) { await using stack = new AsyncDisposableStack(); const fixture = stack.use(await testdir()); const remote = fixture.path; @@ -108,6 +109,26 @@ export async function createLocalRemote(dir: Fixture) { return moveDisposable(stack, fixture); } +export async function createGitHttpRemote(options: { + files: Fixture; + expectedAuthorization: string; +}) { + await using stack = new AsyncDisposableStack(); + const fixture = stack.use(await createLocalRemote(options.files)); + const server = stack.use( + await createGitHttpServer({ + projectRoot: path.dirname(fixture.path), + expectedAuthorization: options.expectedAuthorization, + }), + ); + + return moveDisposable(stack, { + path: fixture.path, + url: `${server.origin}/${path.basename(fixture.path)}`, + requests: server.requests, + }); +} + export async function shallowClone(cwd: string, depth = 1) { await using stack = new AsyncDisposableStack(); const fixture = stack.use(await testdir()); From c8d942b286e43f81bfafe668f4bdc78505e0c897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Thu, 30 Jul 2026 00:46:16 +0200 Subject: [PATCH 12/24] refactor --- src/github.test.ts | 16 ++++++++-------- src/test-utils/index.ts | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/github.test.ts b/src/github.test.ts index 381c93fa..d03afdf7 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -66,10 +66,10 @@ describe("GitHub", () => { await using _gitConfig = await isolateGitConfig(); const actionToken = "action-token"; const checkoutToken = "checkout-token"; - await using remote = await createGitHttpRemote({ - files: { "file.txt": "initial\n" }, - expectedAuthorization: getAuthorization(actionToken), - }); + await using remote = await createGitHttpRemote( + getAuthorization(actionToken), + { "file.txt": "initial\n" }, + ); await using repositoryFixture = await shallowClone(remote.path); const repository = repositoryFixture.path; @@ -114,10 +114,10 @@ describe("GitHub", () => { it("uses github-token instead of credentials embedded in the CLI push URL", async () => { await using _gitConfig = await isolateGitConfig(); const actionToken = "action-token"; - await using remote = await createGitHttpRemote({ - files: { "file.txt": "initial\n" }, - expectedAuthorization: getAuthorization(actionToken), - }); + await using remote = await createGitHttpRemote( + getAuthorization(actionToken), + { "file.txt": "initial\n" }, + ); await using repositoryFixture = await shallowClone(remote.path); const repository = repositoryFixture.path; diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts index 1de2dc13..4d9823e0 100644 --- a/src/test-utils/index.ts +++ b/src/test-utils/index.ts @@ -109,16 +109,16 @@ async function createLocalRemote(dir: Fixture) { return moveDisposable(stack, fixture); } -export async function createGitHttpRemote(options: { - files: Fixture; - expectedAuthorization: string; -}) { +export async function createGitHttpRemote( + expectedAuthorization: string, + files: Fixture, +) { await using stack = new AsyncDisposableStack(); - const fixture = stack.use(await createLocalRemote(options.files)); + const fixture = stack.use(await createLocalRemote(files)); const server = stack.use( await createGitHttpServer({ projectRoot: path.dirname(fixture.path), - expectedAuthorization: options.expectedAuthorization, + expectedAuthorization, }), ); From a834e17d84e7be81defcd7d3d4f46ca89c8a5072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Thu, 30 Jul 2026 00:49:26 +0200 Subject: [PATCH 13/24] reuse helpers --- src/pr-status/worktree.test.ts | 49 +++++++++++++--------------------- 1 file changed, 18 insertions(+), 31 deletions(-) diff --git a/src/pr-status/worktree.test.ts b/src/pr-status/worktree.test.ts index 1c3e1505..0681f4c2 100644 --- a/src/pr-status/worktree.test.ts +++ b/src/pr-status/worktree.test.ts @@ -1,9 +1,9 @@ import { pathToFileURL } from "node:url"; import type * as github from "@actions/github"; import { getReleasePlan } from "@changesets/get-release-plan"; -import { createFixture } from "fs-fixture"; import { exec } from "tinyexec"; import { describe, expect, it } from "vitest"; +import { gitdir, shallowClone, testdir } from "../test-utils/index.ts"; import { getPullRequestWorktree } from "./worktree.ts"; type PullRequestContext = NonNullable< @@ -21,7 +21,7 @@ async function git(cwd: string, args: string[]) { describe("getPullRequestWorktree", () => { it("fetches a PR branch into a detached worktree and keeps the main checkout untouched", async () => { // Local source repo - await using sourceRepoFixture = await createFixture({ + await using sourceRepoFixture = await gitdir({ ".changeset/config.json": JSON.stringify({}), "package.json": JSON.stringify({ name: "repo", @@ -35,46 +35,33 @@ describe("getPullRequestWorktree", () => { }), }); const sourceRepo = sourceRepoFixture.path; - await git(sourceRepo, ["init", "-b", "main"]); - await git(sourceRepo, ["config", "user.name", "Test User"]); - await git(sourceRepo, ["config", "user.email", "test@example.com"]); - await git(sourceRepo, ["add", "."]); - await git(sourceRepo, ["commit", "-m", "base"]); // Simulate remote bare git server - await using originBareFixture = await createFixture(); + await using originBareFixture = await testdir(); const originBare = originBareFixture.path; - await git(originBare, ["clone", "--bare", sourceRepo, originBare]); + await git(originBare, [ + "clone", + "--bare", + pathToFileURL(sourceRepo).toString(), + ".", + ]); // Simulate checkout PR in github action - await using checkoutRepoFixture = await createFixture(); + await using checkoutRepoFixture = await shallowClone(originBare); const checkoutRepo = checkoutRepoFixture.path; - await git(checkoutRepo, [ - "clone", - "--depth", - "1", - "--branch", - "main", - pathToFileURL(originBare).toString(), - checkoutRepo, - ]); // Simulate remote fork bare git server - await using forkBareFixture = await createFixture(); + await using forkBareFixture = await testdir(); const forkBare = forkBareFixture.path; - await git(forkBare, ["clone", "--bare", originBare, forkBare]); - - await using forkRepoFixture = await createFixture(); - const forkRepo = forkRepoFixture.path; - await git(forkRepo, [ + await git(forkBare, [ "clone", - "--depth", - "1", - "--branch", - "main", - pathToFileURL(forkBare).toString(), - forkRepo, + "--bare", + pathToFileURL(originBare).toString(), + ".", ]); + + await using forkRepoFixture = await shallowClone(forkBare); + const forkRepo = forkRepoFixture.path; await git(forkRepo, ["config", "user.name", "Test User"]); await git(forkRepo, ["config", "user.email", "test@example.com"]); await git(forkRepo, ["checkout", "-b", "feature"]); From 8e4ec1e6dc79bceadd16a74fff001b5cb92bd851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Thu, 30 Jul 2026 00:53:55 +0200 Subject: [PATCH 14/24] refactor --- src/github.test.ts | 5 ++--- src/github.ts | 15 +++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/github.test.ts b/src/github.test.ts index d03afdf7..df5dd2c5 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -15,7 +15,6 @@ const githubContext = vi.hoisted(() => ({ owner: "changesets", repo: "action", }, - serverUrl: "http://127.0.0.1", sha: "base-sha", })); @@ -74,7 +73,6 @@ describe("GitHub", () => { const repository = repositoryFixture.path; const serverUrl = new URL(remote.url).origin; - githubContext.serverUrl = serverUrl; await git(repository, ["remote", "set-url", "origin", remote.url]); await git(repository, [ "config", @@ -87,6 +85,7 @@ describe("GitHub", () => { cwd: repository, githubToken: actionToken, pushWithGitCli: true, + serverUrl, }); await github.pushChanges({ @@ -122,7 +121,6 @@ describe("GitHub", () => { const repository = repositoryFixture.path; const remoteUrl = new URL(remote.url); - githubContext.serverUrl = remoteUrl.origin; remoteUrl.username = "x-access-token"; remoteUrl.password = "checkout-token"; await git(repository, ["remote", "set-url", "origin", remoteUrl.href]); @@ -139,6 +137,7 @@ describe("GitHub", () => { cwd: repository, githubToken: actionToken, pushWithGitCli: true, + serverUrl: remoteUrl.origin, }); await github.pushChanges({ diff --git a/src/github.ts b/src/github.ts index f396b43d..f3f50013 100644 --- a/src/github.ts +++ b/src/github.ts @@ -72,15 +72,23 @@ export class GitHub { readonly octokit: Octokit; readonly cwd: string; readonly pushWithGitCli: boolean; + readonly serverUrl: string; constructor(options: { githubToken: string; cwd: string; pushWithGitCli?: boolean; + serverUrl?: string; }) { this.#githubToken = options.githubToken; this.cwd = options.cwd; this.pushWithGitCli = options.pushWithGitCli ?? false; + this.serverUrl = ( + options.serverUrl ?? + context.serverUrl ?? + process.env.GITHUB_SERVER_URL ?? + "https://github.com" + ).replace(/\/+$/, ""); this.octokit = setupOctokit(options.githubToken); } @@ -92,11 +100,6 @@ export class GitHub { const basic = Buffer.from(`x-access-token:${this.#githubToken}`).toString( "base64", ); - const serverUrl = ( - context.serverUrl ?? - process.env.GITHUB_SERVER_URL ?? - "https://github.com" - ).replace(/\/+$/, ""); const gitConfigCount = Number(process.env.GIT_CONFIG_COUNT ?? 0); if (!Number.isInteger(gitConfigCount) || gitConfigCount < 0) { throw new Error( @@ -121,7 +124,7 @@ export class GitHub { // 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`]); + const extraHeaderKeys = new Set([`http.${this.serverUrl}/.extraheader`]); for (const remoteUrl of stdout.split(/\r?\n/)) { const httpUrl = getHttpUrl(remoteUrl); if (httpUrl !== undefined) { From 9ac84a76bfc06ca7e311db7f215324595f69290f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Thu, 30 Jul 2026 01:06:57 +0200 Subject: [PATCH 15/24] refactor --- src/github.test.ts | 14 ++++++------- src/test-utils/gitHttpServer.ts | 37 +++++++++------------------------ src/test-utils/index.ts | 10 ++------- 3 files changed, 18 insertions(+), 43 deletions(-) diff --git a/src/github.test.ts b/src/github.test.ts index df5dd2c5..dbfdccf6 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -65,10 +65,9 @@ describe("GitHub", () => { await using _gitConfig = await isolateGitConfig(); const actionToken = "action-token"; const checkoutToken = "checkout-token"; - await using remote = await createGitHttpRemote( - getAuthorization(actionToken), - { "file.txt": "initial\n" }, - ); + await using remote = await createGitHttpRemote({ + "file.txt": "initial\n", + }); await using repositoryFixture = await shallowClone(remote.path); const repository = repositoryFixture.path; @@ -113,10 +112,9 @@ describe("GitHub", () => { it("uses github-token instead of credentials embedded in the CLI push URL", async () => { await using _gitConfig = await isolateGitConfig(); const actionToken = "action-token"; - await using remote = await createGitHttpRemote( - getAuthorization(actionToken), - { "file.txt": "initial\n" }, - ); + await using remote = await createGitHttpRemote({ + "file.txt": "initial\n", + }); await using repositoryFixture = await shallowClone(remote.path); const repository = repositoryFixture.path; diff --git a/src/test-utils/gitHttpServer.ts b/src/test-utils/gitHttpServer.ts index 732c8461..b210d665 100644 --- a/src/test-utils/gitHttpServer.ts +++ b/src/test-utils/gitHttpServer.ts @@ -23,9 +23,9 @@ function recordRequest(request: IncomingMessage): RecordedRequest { } async function runGitHttpBackend( + cwd: string, request: IncomingMessage, response: ServerResponse, - projectRoot: string, ) { const requestUrl = new URL( request.url ?? "/", @@ -36,7 +36,7 @@ async function runGitHttpBackend( CONTENT_LENGTH: request.headers["content-length"] ?? "0", GATEWAY_INTERFACE: "CGI/1.1", GIT_HTTP_EXPORT_ALL: "1", - GIT_PROJECT_ROOT: projectRoot, + GIT_PROJECT_ROOT: cwd, PATH_INFO: decodeURIComponent(requestUrl.pathname), QUERY_STRING: requestUrl.search.slice(1), REMOTE_ADDR: request.socket.remoteAddress ?? "", @@ -115,36 +115,19 @@ async function listen(server: http.Server) { } } -export async function createGitHttpServer(options: { - projectRoot: string; - expectedAuthorization: string; -}) { +export async function createGitHttpServer(cwd: string) { const requests: RecordedRequest[] = []; const server = http.createServer((request, response) => { const recordedRequest = recordRequest(request); requests.push(recordedRequest); - const authorizationHeaders = recordedRequest.headers.authorization ?? []; - - if ( - authorizationHeaders.length !== 1 || - authorizationHeaders[0] !== options.expectedAuthorization - ) { - response.writeHead(401, { - "WWW-Authenticate": 'Basic realm="changesets-action-test"', - }); - response.end(); - return; - } - void runGitHttpBackend(request, response, options.projectRoot).catch( - (error: unknown) => { - response.destroy( - Error.isError(error) - ? error - : new Error("Server error", { cause: error }), - ); - }, - ); + void runGitHttpBackend(cwd, request, response).catch((error: unknown) => { + response.destroy( + Error.isError(error) + ? error + : new Error("Server error", { cause: error }), + ); + }); }); await listen(server); diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts index 4d9823e0..44990b6c 100644 --- a/src/test-utils/index.ts +++ b/src/test-utils/index.ts @@ -109,17 +109,11 @@ async function createLocalRemote(dir: Fixture) { return moveDisposable(stack, fixture); } -export async function createGitHttpRemote( - expectedAuthorization: string, - files: 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({ - projectRoot: path.dirname(fixture.path), - expectedAuthorization, - }), + await createGitHttpServer(path.dirname(fixture.path)), ); return moveDisposable(stack, { From 008f900204afbf170327aaa65a345edc5a2110ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Thu, 30 Jul 2026 01:08:35 +0200 Subject: [PATCH 16/24] simplify --- src/test-utils/gitHttpServer.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/test-utils/gitHttpServer.ts b/src/test-utils/gitHttpServer.ts index b210d665..3907156e 100644 --- a/src/test-utils/gitHttpServer.ts +++ b/src/test-utils/gitHttpServer.ts @@ -5,20 +5,14 @@ import http, { type IncomingMessage, type ServerResponse } from "node:http"; type RecordedRequest = { method: string; url: string; - headers: Record; + headers: NodeJS.Dict; }; function recordRequest(request: IncomingMessage): RecordedRequest { - const headers: Record = {}; - for (let index = 0; index < request.rawHeaders.length; index += 2) { - const name = request.rawHeaders[index]?.toLowerCase(); - if (name === undefined) continue; - (headers[name] ??= []).push(request.rawHeaders[index + 1] ?? ""); - } return { method: request.method ?? "GET", url: request.url ?? "/", - headers, + headers: request.headersDistinct, }; } From 33a1f150ca924bdd135c1a7bfd3eb309261d565d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Thu, 30 Jul 2026 01:12:29 +0200 Subject: [PATCH 17/24] add tests --- src/github.test.ts | 137 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/src/github.test.ts b/src/github.test.ts index dbfdccf6..eccdba2c 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -154,4 +154,141 @@ describe("GitHub", () => { remote.requests.map((request) => request.headers.authorization), ).toEqual(remote.requests.map(() => [getAuthorization(actionToken)])); }, 15_000); + + it("uses the push URL when it differs from the fetch URL", async () => { + await using _gitConfig = await isolateGitConfig(); + const actionToken = "action-token"; + await using fetchRemote = await createGitHttpRemote({ + "file.txt": "initial\n", + }); + await using pushRemote = await createGitHttpRemote({ + "file.txt": "initial\n", + }); + await using repositoryFixture = await shallowClone(fetchRemote.path); + const repository = repositoryFixture.path; + + await git(repository, ["remote", "set-url", "origin", fetchRemote.url]); + await git(repository, ["config", "remote.origin.pushurl", pushRemote.url]); + + await fs.writeFile(path.join(repository, "file.txt"), "changed\n"); + const github = new GitHub({ + cwd: repository, + githubToken: actionToken, + pushWithGitCli: true, + serverUrl: new URL(fetchRemote.url).origin, + }); + + await github.pushChanges({ + branch: "changeset-release/main", + message: "Version Packages", + }); + + expect(fetchRemote.requests).toEqual([]); + expect( + await git(pushRemote.path, [ + "rev-parse", + "refs/heads/changeset-release/main", + ]), + ).toBe(await git(repository, ["rev-parse", "HEAD"])); + expect(pushRemote.requests.length).toBeGreaterThan(0); + expect( + pushRemote.requests.map((request) => request.headers.authorization), + ).toEqual(pushRemote.requests.map(() => [getAuthorization(actionToken)])); + }, 15_000); + + it("uses github-token for every push URL", async () => { + await using _gitConfig = await isolateGitConfig(); + const actionToken = "action-token"; + await using firstRemote = await createGitHttpRemote({ + "file.txt": "initial\n", + }); + await using secondRemote = await createGitHttpRemote({ + "file.txt": "initial\n", + }); + await using repositoryFixture = await shallowClone(firstRemote.path); + const repository = repositoryFixture.path; + + await git(repository, [ + "config", + "--add", + "remote.origin.pushurl", + firstRemote.url, + ]); + await git(repository, [ + "config", + "--add", + "remote.origin.pushurl", + secondRemote.url, + ]); + + await fs.writeFile(path.join(repository, "file.txt"), "changed\n"); + const github = new GitHub({ + cwd: repository, + githubToken: actionToken, + pushWithGitCli: true, + serverUrl: new URL(firstRemote.url).origin, + }); + + await github.pushChanges({ + branch: "changeset-release/main", + message: "Version Packages", + }); + + const head = await git(repository, ["rev-parse", "HEAD"]); + for (const remote of [firstRemote, secondRemote]) { + expect( + await git(remote.path, [ + "rev-parse", + "refs/heads/changeset-release/main", + ]), + ).toBe(head); + expect(remote.requests.length).toBeGreaterThan(0); + expect( + remote.requests.map((request) => request.headers.authorization), + ).toEqual(remote.requests.map(() => [getAuthorization(actionToken)])); + } + }, 15_000); + + it("preserves existing command-scoped Git config entries", async () => { + await using _gitConfig = await isolateGitConfig(); + const actionToken = "action-token"; + await using fetchRemote = await createGitHttpRemote({ + "file.txt": "initial\n", + }); + await using pushRemote = await createGitHttpRemote({ + "file.txt": "initial\n", + }); + await using repositoryFixture = await shallowClone(fetchRemote.path); + const repository = repositoryFixture.path; + + await git(repository, ["remote", "set-url", "origin", fetchRemote.url]); + vi.stubEnv("GIT_CONFIG_COUNT", "1"); + vi.stubEnv("GIT_CONFIG_KEY_0", "remote.origin.pushurl"); + vi.stubEnv("GIT_CONFIG_VALUE_0", pushRemote.url); + + await fs.writeFile(path.join(repository, "file.txt"), "changed\n"); + const github = new GitHub({ + cwd: repository, + githubToken: actionToken, + pushWithGitCli: true, + serverUrl: new URL(fetchRemote.url).origin, + }); + + await github.pushChanges({ + branch: "changeset-release/main", + message: "Version Packages", + }); + + expect(fetchRemote.requests).toEqual([]); + expect( + await git(pushRemote.path, [ + "rev-parse", + "refs/heads/changeset-release/main", + ]), + ).toBe(await git(repository, ["rev-parse", "HEAD"])); + expect(pushRemote.requests.length).toBeGreaterThan(0); + expect( + pushRemote.requests.map((request) => request.headers.authorization), + ).toEqual(pushRemote.requests.map(() => [getAuthorization(actionToken)])); + }, 15_000); }); From 57b6b8d97c181e76fbbcfb14688b9ff0be435f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Thu, 30 Jul 2026 01:13:56 +0200 Subject: [PATCH 18/24] refactor --- src/github.test.ts | 6 +- src/pr-status/worktree.test.ts | 2 +- .../gitHttpServer.ts => test-utils.ts} | 140 +++++++++++++++++- src/test-utils/index.ts | 139 ----------------- 4 files changed, 141 insertions(+), 146 deletions(-) rename src/{test-utils/gitHttpServer.ts => test-utils.ts} (51%) delete mode 100644 src/test-utils/index.ts diff --git a/src/github.test.ts b/src/github.test.ts index eccdba2c..29d732a3 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -4,11 +4,7 @@ import path from "node:path"; import { exec } from "tinyexec"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GitHub } from "./github.ts"; -import { - createGitHttpRemote, - shallowClone, - testdir, -} from "./test-utils/index.ts"; +import { createGitHttpRemote, shallowClone, testdir } from "./test-utils.ts"; const githubContext = vi.hoisted(() => ({ repo: { diff --git a/src/pr-status/worktree.test.ts b/src/pr-status/worktree.test.ts index 0681f4c2..0ca13c70 100644 --- a/src/pr-status/worktree.test.ts +++ b/src/pr-status/worktree.test.ts @@ -3,7 +3,7 @@ import type * as github from "@actions/github"; import { getReleasePlan } from "@changesets/get-release-plan"; import { exec } from "tinyexec"; import { describe, expect, it } from "vitest"; -import { gitdir, shallowClone, testdir } from "../test-utils/index.ts"; +import { gitdir, shallowClone, testdir } from "../test-utils.ts"; import { getPullRequestWorktree } from "./worktree.ts"; type PullRequestContext = NonNullable< diff --git a/src/test-utils/gitHttpServer.ts b/src/test-utils.ts similarity index 51% rename from src/test-utils/gitHttpServer.ts rename to src/test-utils.ts index 3907156e..3882d314 100644 --- a/src/test-utils/gitHttpServer.ts +++ b/src/test-utils.ts @@ -1,6 +1,105 @@ 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); +} type RecordedRequest = { method: string; @@ -109,7 +208,7 @@ async function listen(server: http.Server) { } } -export async function createGitHttpServer(cwd: string) { +async function createGitHttpServer(cwd: string) { const requests: RecordedRequest[] = []; const server = http.createServer((request, response) => { const recordedRequest = recordRequest(request); @@ -147,3 +246,42 @@ export async function createGitHttpServer(cwd: string) { }, }; } + +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, + }); +} diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts deleted file mode 100644 index 44990b6c..00000000 --- a/src/test-utils/index.ts +++ /dev/null @@ -1,139 +0,0 @@ -import fsp from "node:fs/promises"; -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"; -import { createGitHttpServer } from "./gitHttpServer.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); -} - -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, - }); -} - -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); -} From 4e5a52de972a7f1372d6553f41ab408efc8d19d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Mon, 3 Aug 2026 10:50:19 +0200 Subject: [PATCH 19/24] improve comments --- src/github.ts | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/github.ts b/src/github.ts index f3f50013..802d3818 100644 --- a/src/github.ts +++ b/src/github.ts @@ -97,18 +97,29 @@ export class GitHub { } async #getCliAuthEnv(): Promise> { + // Make `github-token` authoritative for CLI pushes without changing the + // repository config. Git may otherwise use checkout's persisted header or + // credentials embedded in a remote URL, so we need to install command-scoped + // `http.extraHeader` overrides for every push destination. + // + // Historical context: v1 put `github-token` in ~/.netrc, where checkout's + // extraheader took precedence. This setup with reset + authHeader + // deliberately makes `github-token` win for pushes. const basic = Buffer.from(`x-access-token:${this.#githubToken}`).toString( "base64", ); + + // The environment may already contain command-scoped Git config. Append + // our KEY_n/VALUE_n entries so those existing settings stay active. const gitConfigCount = Number(process.env.GIT_CONFIG_COUNT ?? 0); if (!Number.isInteger(gitConfigCount) || gitConfigCount < 0) { throw new Error( `Invalid GIT_CONFIG_COUNT value: ${process.env.GIT_CONFIG_COUNT}`, ); } - // `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. + + // `git push origin` prefers remote.origin.pushurl over the fetch URL and + // supports more than one push URL, so inspect every effective destination. const { stdout } = await getExecOutput( "git", ["remote", "get-url", "--push", "--all", "origin"], @@ -120,10 +131,10 @@ export class GitHub { }, ); - // 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. + // Git reads `http.extraHeader` from the most specific matching URL section. + // The host key replaces checkout's usual persisted header; an exact key for + // each destination outranks path- or username-specific inherited config. + // Git selects only one URL section, so these keys do not duplicate headers. const extraHeaderKeys = new Set([`http.${this.serverUrl}/.extraheader`]); for (const remoteUrl of stdout.split(/\r?\n/)) { const httpUrl = getHttpUrl(remoteUrl); @@ -132,19 +143,16 @@ export class GitHub { } } const authHeader = `AUTHORIZATION: basic ${basic}`; + + // Each URL key needs two new command-scoped config entries: one to reset + // the inherited header list and one to install our Authorization header. 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. + // `http.extraHeader` is multi-valued, so merely appending our header could + // make Git send both tokens. An empty value resets inherited values; the + // following value adds only ours. let index = 0; for (const extraHeaderKey of extraHeaderKeys) { const resetIndex = gitConfigCount + index * 2; From 86f0bdda85c9423026388aa6393c11179be86d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Mon, 3 Aug 2026 10:52:55 +0200 Subject: [PATCH 20/24] shift helper --- src/test-utils.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/test-utils.ts b/src/test-utils.ts index 3882d314..ebc4bdd5 100644 --- a/src/test-utils.ts +++ b/src/test-utils.ts @@ -101,20 +101,6 @@ export async function shallowClone(cwd: string, depth = 1) { return moveDisposable(stack, fixture); } -type RecordedRequest = { - method: string; - url: string; - headers: NodeJS.Dict; -}; - -function recordRequest(request: IncomingMessage): RecordedRequest { - return { - method: request.method ?? "GET", - url: request.url ?? "/", - headers: request.headersDistinct, - }; -} - async function runGitHttpBackend( cwd: string, request: IncomingMessage, @@ -208,6 +194,20 @@ async function listen(server: http.Server) { } } +type RecordedRequest = { + method: string; + url: string; + headers: NodeJS.Dict; +}; + +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) => { From 59ac098de43c88fafa8e40bc44db2cfef5642dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Mon, 3 Aug 2026 10:58:23 +0200 Subject: [PATCH 21/24] add comments --- src/test-utils.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/test-utils.ts b/src/test-utils.ts index ebc4bdd5..dfb834dd 100644 --- a/src/test-utils.ts +++ b/src/test-utils.ts @@ -106,6 +106,8 @@ async function runGitHttpBackend( 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"}`, @@ -132,6 +134,8 @@ async function runGitHttpBackend( }); 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)); @@ -148,6 +152,7 @@ async function runGitHttpBackend( } 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) { @@ -158,6 +163,7 @@ async function runGitHttpBackend( 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) { From f4cfd52d751c664054b4d91a813e75e33dd1fafe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Mon, 3 Aug 2026 11:20:42 +0200 Subject: [PATCH 22/24] extract test helpers --- src/github.test.ts | 237 +++++++++++++++++---------------------------- 1 file changed, 87 insertions(+), 150 deletions(-) diff --git a/src/github.test.ts b/src/github.test.ts index 29d732a3..69e9b822 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -31,13 +31,53 @@ function getAuthorization(token: string) { return `basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`; } +function createRemote() { + return createGitHttpRemote({ "file.txt": "initial\n" }); +} + async function isolateGitConfig() { const fixture = await testdir({ "global.gitconfig": "" }); vi.stubEnv("GIT_CONFIG_GLOBAL", path.join(fixture.path, "global.gitconfig")); return fixture; } +type GitHttpRemote = Awaited>; + +async function pushChangedFile( + repository: string, + serverUrl: string, + token: string, +) { + await fs.writeFile(path.join(repository, "file.txt"), "changed\n"); + const github = new GitHub({ + cwd: repository, + githubToken: token, + pushWithGitCli: true, + serverUrl, + }); + await github.pushChanges({ + branch: "changeset-release/main", + message: "Version Packages", + }); + return github; +} + +async function expectReleaseBranch(remote: GitHttpRemote, repository: string) { + expect( + await git(remote.path, ["rev-parse", "refs/heads/changeset-release/main"]), + ).toBe(await git(repository, ["rev-parse", "HEAD"])); +} + +function expectRequestsToUseToken(remote: GitHttpRemote, token: string) { + expect(remote.requests.length).toBeGreaterThan(0); + for (const request of remote.requests) { + expect(request.headers.authorization).toEqual([getAuthorization(token)]); + } +} + beforeEach(() => { + vi.stubEnv("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z"); + vi.stubEnv("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z"); vi.stubEnv("GIT_CONFIG_COUNT", "0"); vi.stubEnv("GIT_CONFIG_NOSYSTEM", "1"); vi.stubEnv("GIT_TERMINAL_PROMPT", "0"); @@ -57,17 +97,15 @@ describe("GitHub", () => { expect(github.pushWithGitCli).toBe(false); }); - it("uses github-token instead of checkout's persisted header for CLI branch and tag pushes", async () => { + it("uses github-token instead of checkout's persisted header for branch and tag pushes", async () => { await using _gitConfig = await isolateGitConfig(); const actionToken = "action-token"; const checkoutToken = "checkout-token"; - await using remote = await createGitHttpRemote({ - "file.txt": "initial\n", - }); + await using remote = await createRemote(); await using repositoryFixture = await shallowClone(remote.path); const repository = repositoryFixture.path; - const serverUrl = new URL(remote.url).origin; + await git(repository, ["remote", "set-url", "origin", remote.url]); await git(repository, [ "config", @@ -75,42 +113,21 @@ describe("GitHub", () => { `AUTHORIZATION: ${getAuthorization(checkoutToken)}`, ]); - await fs.writeFile(path.join(repository, "file.txt"), "changed\n"); - const github = new GitHub({ - cwd: repository, - githubToken: actionToken, - pushWithGitCli: true, - serverUrl, - }); - - await github.pushChanges({ - branch: "changeset-release/main", - message: "Version Packages", - }); + const github = await pushChangedFile(repository, serverUrl, actionToken); await git(repository, ["tag", "v1.0.0"]); await github.pushTag("v1.0.0"); - expect( - await git(remote.path, [ - "rev-parse", - "refs/heads/changeset-release/main", - ]), - ).toBe(await git(repository, ["rev-parse", "HEAD"])); + await expectReleaseBranch(remote, repository); expect(await git(remote.path, ["rev-parse", "refs/tags/v1.0.0"])).toBe( await git(repository, ["rev-parse", "v1.0.0"]), ); - expect(remote.requests.length).toBeGreaterThan(0); - expect( - remote.requests.map((request) => request.headers.authorization), - ).toEqual(remote.requests.map(() => [getAuthorization(actionToken)])); + expectRequestsToUseToken(remote, actionToken); }, 15_000); - it("uses github-token instead of credentials embedded in the CLI push URL", async () => { + it("uses github-token instead of credentials embedded in the push URL", async () => { await using _gitConfig = await isolateGitConfig(); const actionToken = "action-token"; - await using remote = await createGitHttpRemote({ - "file.txt": "initial\n", - }); + await using remote = await createRemote(); await using repositoryFixture = await shallowClone(remote.path); const repository = repositoryFixture.path; @@ -118,6 +135,7 @@ describe("GitHub", () => { remoteUrl.username = "x-access-token"; remoteUrl.password = "checkout-token"; await git(repository, ["remote", "set-url", "origin", remoteUrl.href]); + const persistedCredentialUrl = new URL(remoteUrl); persistedCredentialUrl.password = ""; await git(repository, [ @@ -126,134 +144,68 @@ describe("GitHub", () => { `AUTHORIZATION: ${getAuthorization("checkout-token")}`, ]); - await fs.writeFile(path.join(repository, "file.txt"), "changed\n"); - const github = new GitHub({ - cwd: repository, - githubToken: actionToken, - pushWithGitCli: true, - serverUrl: remoteUrl.origin, - }); - - await github.pushChanges({ - branch: "changeset-release/main", - message: "Version Packages", - }); + await pushChangedFile(repository, remoteUrl.origin, actionToken); - expect( - await git(remote.path, [ - "rev-parse", - "refs/heads/changeset-release/main", - ]), - ).toBe(await git(repository, ["rev-parse", "HEAD"])); - expect(remote.requests.length).toBeGreaterThan(0); - expect( - remote.requests.map((request) => request.headers.authorization), - ).toEqual(remote.requests.map(() => [getAuthorization(actionToken)])); + await expectReleaseBranch(remote, repository); + expectRequestsToUseToken(remote, actionToken); }, 15_000); it("uses the push URL when it differs from the fetch URL", async () => { await using _gitConfig = await isolateGitConfig(); const actionToken = "action-token"; - await using fetchRemote = await createGitHttpRemote({ - "file.txt": "initial\n", - }); - await using pushRemote = await createGitHttpRemote({ - "file.txt": "initial\n", - }); + await using fetchRemote = await createRemote(); + await using pushRemote = await createRemote(); await using repositoryFixture = await shallowClone(fetchRemote.path); const repository = repositoryFixture.path; await git(repository, ["remote", "set-url", "origin", fetchRemote.url]); await git(repository, ["config", "remote.origin.pushurl", pushRemote.url]); - await fs.writeFile(path.join(repository, "file.txt"), "changed\n"); - const github = new GitHub({ - cwd: repository, - githubToken: actionToken, - pushWithGitCli: true, - serverUrl: new URL(fetchRemote.url).origin, - }); - - await github.pushChanges({ - branch: "changeset-release/main", - message: "Version Packages", - }); + await pushChangedFile( + repository, + new URL(fetchRemote.url).origin, + actionToken, + ); expect(fetchRemote.requests).toEqual([]); - expect( - await git(pushRemote.path, [ - "rev-parse", - "refs/heads/changeset-release/main", - ]), - ).toBe(await git(repository, ["rev-parse", "HEAD"])); - expect(pushRemote.requests.length).toBeGreaterThan(0); - expect( - pushRemote.requests.map((request) => request.headers.authorization), - ).toEqual(pushRemote.requests.map(() => [getAuthorization(actionToken)])); + await expectReleaseBranch(pushRemote, repository); + expectRequestsToUseToken(pushRemote, actionToken); }, 15_000); it("uses github-token for every push URL", async () => { await using _gitConfig = await isolateGitConfig(); const actionToken = "action-token"; - await using firstRemote = await createGitHttpRemote({ - "file.txt": "initial\n", - }); - await using secondRemote = await createGitHttpRemote({ - "file.txt": "initial\n", - }); + await using firstRemote = await createRemote(); + await using secondRemote = await createRemote(); await using repositoryFixture = await shallowClone(firstRemote.path); const repository = repositoryFixture.path; - await git(repository, [ - "config", - "--add", - "remote.origin.pushurl", - firstRemote.url, - ]); - await git(repository, [ - "config", - "--add", - "remote.origin.pushurl", - secondRemote.url, - ]); - - await fs.writeFile(path.join(repository, "file.txt"), "changed\n"); - const github = new GitHub({ - cwd: repository, - githubToken: actionToken, - pushWithGitCli: true, - serverUrl: new URL(firstRemote.url).origin, - }); + for (const remote of [firstRemote, secondRemote]) { + await git(repository, [ + "config", + "--add", + "remote.origin.pushurl", + remote.url, + ]); + } - await github.pushChanges({ - branch: "changeset-release/main", - message: "Version Packages", - }); + await pushChangedFile( + repository, + new URL(firstRemote.url).origin, + actionToken, + ); - const head = await git(repository, ["rev-parse", "HEAD"]); for (const remote of [firstRemote, secondRemote]) { - expect( - await git(remote.path, [ - "rev-parse", - "refs/heads/changeset-release/main", - ]), - ).toBe(head); - expect(remote.requests.length).toBeGreaterThan(0); - expect( - remote.requests.map((request) => request.headers.authorization), - ).toEqual(remote.requests.map(() => [getAuthorization(actionToken)])); + await expectReleaseBranch(remote, repository); + expectRequestsToUseToken(remote, actionToken); } }, 15_000); it("preserves existing command-scoped Git config entries", async () => { await using _gitConfig = await isolateGitConfig(); const actionToken = "action-token"; - await using fetchRemote = await createGitHttpRemote({ - "file.txt": "initial\n", - }); - await using pushRemote = await createGitHttpRemote({ - "file.txt": "initial\n", - }); + await using fetchRemote = await createRemote(); + await using pushRemote = await createRemote(); await using repositoryFixture = await shallowClone(fetchRemote.path); const repository = repositoryFixture.path; @@ -262,29 +214,14 @@ describe("GitHub", () => { vi.stubEnv("GIT_CONFIG_KEY_0", "remote.origin.pushurl"); vi.stubEnv("GIT_CONFIG_VALUE_0", pushRemote.url); - await fs.writeFile(path.join(repository, "file.txt"), "changed\n"); - const github = new GitHub({ - cwd: repository, - githubToken: actionToken, - pushWithGitCli: true, - serverUrl: new URL(fetchRemote.url).origin, - }); - - await github.pushChanges({ - branch: "changeset-release/main", - message: "Version Packages", - }); + await pushChangedFile( + repository, + new URL(fetchRemote.url).origin, + actionToken, + ); expect(fetchRemote.requests).toEqual([]); - expect( - await git(pushRemote.path, [ - "rev-parse", - "refs/heads/changeset-release/main", - ]), - ).toBe(await git(repository, ["rev-parse", "HEAD"])); - expect(pushRemote.requests.length).toBeGreaterThan(0); - expect( - pushRemote.requests.map((request) => request.headers.authorization), - ).toEqual(pushRemote.requests.map(() => [getAuthorization(actionToken)])); + await expectReleaseBranch(pushRemote, repository); + expectRequestsToUseToken(pushRemote, actionToken); }, 15_000); }); From 387d206714a022383875262222dfa864134f91cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Mon, 3 Aug 2026 12:21:13 +0200 Subject: [PATCH 23/24] update docs and changesets --- .changeset/brave-pandas-commit.md | 6 +++++- README.md | 35 +++++++++++++++++++------------ action.yml | 6 +++++- pr-comment/README.md | 2 +- pr-comment/action.yml | 4 +++- publish/README.md | 14 ++++++------- publish/action.yml | 4 +++- src/index.ts | 3 ++- src/utils.test.ts | 20 +++++++++++++++++- src/utils.ts | 17 +++++++++++++++ src/version/index.ts | 2 ++ version/README.md | 18 ++++++++-------- version/action.yml | 2 ++ 13 files changed, 97 insertions(+), 36 deletions(-) diff --git a/.changeset/brave-pandas-commit.md b/.changeset/brave-pandas-commit.md index 2033a599..08e7e36d 100644 --- a/.changeset/brave-pandas-commit.md +++ b/.changeset/brave-pandas-commit.md @@ -2,4 +2,8 @@ "@changesets/action": major --- -Replace `commit-mode` with the boolean `push-with-git-cli` input. GitHub API pushes are used by default; set `push-with-git-cli` to `true` to push release commits and tags with the Git CLI. +Release commits and tags are now pushed using the GitHub API by default. + +Replace the `commit-mode` input with the boolean `push-with-git-cli` input. Set `push-with-git-cli: true` to continue using the Git CLI. + +Regardless of the push mode, custom GitHub tokens must be passed explicitly through the `github-token` input. The `GITHUB_TOKEN` environment variable and credentials configured by `actions/checkout` or embedded in remote URLs are not substitutes for this input. When the Git CLI is enabled, `github-token` takes precedence over those repository credentials. diff --git a/README.md b/README.md index 1bd7688f..4b724e7b 100644 --- a/README.md +++ b/README.md @@ -36,23 +36,32 @@ If using [trusted publishing](https://docs.npmjs.com/trusted-publishers), it's r > [!TIP] > Check out [the docs](https://changesets.dev/guide/automating#how-do-i-run-the-version-and-publish-commands) to learn how to set up the version and publish workflow. +> [!IMPORTANT] +> To use a custom GitHub token, pass it explicitly through the `github-token` input: +> +> ```yaml +> with: +> github-token: ${{ secrets.CUSTOM_GITHUB_TOKEN }} +> ``` +> +> Setting the `GITHUB_TOKEN` environment variable does not configure the action. This applies whether release changes are pushed using the GitHub API or the Git CLI. + ### API -| Inputs | Description | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `github-token` | The GitHub token to use for authentication. Defaults to the GitHub-provided token. | -| `publish-script` | The command to use to build and publish packages | -| `version-script` | The command to update version, edit CHANGELOG, read and delete changesets. Default to `changeset version` if not provided | -| `commit-message` | The commit message. Default to `Version Packages` | -| `pr-title` | The pull request title. Default to `Version Packages` | -| `pr-draft` | Controls draft PR behavior. Use 'create' to create new version PRs as draft, or 'always' to also convert existing version PRs back to draft when updating them. | -| `pr-base-branch` | Sets the base branch of the PR. Defaults to `github.ref_name`. | -| `create-github-releases` | Whether to create Github releases after publish | -| `push-git-tags` | Whether to create git tags after publish. If `create-github-releases` is set to `true`, this option will also always be `true`. | -| `push-with-git-cli` | Whether to use the Git CLI instead of the GitHub API to push release commits and tags. Default to `false`. | -| `commit-mode` | An enum to specify the commit mode. Use "git-cli" to push changes using the Git CLI, or "github-api" to push changes via the GitHub API. When using "github-api", all commits and tags are signed using GitHub's GPG key and attributed to the user or app who owns the GITHUB_TOKEN. | +| Inputs | Description | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `github-token` | The GitHub token to use for authentication. Defaults to the GitHub-provided token. To use a custom token, pass it explicitly to this input. | +| `publish-script` | The command to use to build and publish packages | +| `version-script` | The command to update version, edit CHANGELOG, read and delete changesets. Default to `changeset version` if not provided | +| `commit-message` | The commit message. Default to `Version Packages` | +| `pr-title` | The pull request title. Default to `Version Packages` | +| `pr-draft` | Controls draft PR behavior. Use 'create' to create new version PRs as draft, or 'always' to also convert existing version PRs back to draft when updating them. | +| `pr-base-branch` | Sets the base branch of the PR. Defaults to `github.ref_name`. | +| `create-github-releases` | Whether to create Github releases after publish | +| `push-git-tags` | Whether to create git tags after publish. If `create-github-releases` is set to `true`, this option will also always be `true`. | +| `push-with-git-cli` | Whether to use the Git CLI instead of the GitHub API to push release commits and tags. Defaults to `false`. When using the GitHub API, commits and tags are signed using GitHub's GPG key and attributed to the user or app that owns the `github-token`. | | Outputs | Description | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/action.yml b/action.yml index 2d6a1598..e19c373c 100644 --- a/action.yml +++ b/action.yml @@ -5,7 +5,9 @@ runs: main: "dist/index.js" inputs: github-token: - description: "The GitHub token to use for authentication. Defaults to the GitHub-provided token." + description: > + The GitHub token to use for authentication. Defaults to the GitHub-provided token. + To use a custom token, pass it explicitly to this input. required: false default: ${{ github.token }} publish-script: @@ -41,6 +43,8 @@ inputs: push-with-git-cli: description: > Whether to use the Git CLI instead of the GitHub API to push release commits and tags. + Defaults to `false`. When using the GitHub API, commits and tags are signed using + GitHub's GPG key and attributed to the user or app that owns the `github-token`. required: false default: false outputs: diff --git a/pr-comment/README.md b/pr-comment/README.md index 8c5c3c15..50c76cea 100644 --- a/pr-comment/README.md +++ b/pr-comment/README.md @@ -73,7 +73,7 @@ jobs: | Inputs | Description | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `github-token` | The GitHub token to use for authentication. Defaults to the GitHub-provided token. | +| `github-token` | The GitHub token to use for authentication. Defaults to the GitHub-provided token. To use a custom token, pass it explicitly to this input. | | `body` | **Required.** The comment body to post on the PR. | | `update-id` | By default, the action will create and update a comment with this id. Pass a different id to create and update a new comment, or pass an empty string to disable updating comments. | diff --git a/pr-comment/action.yml b/pr-comment/action.yml index 9a6b364e..81dd2291 100644 --- a/pr-comment/action.yml +++ b/pr-comment/action.yml @@ -5,7 +5,9 @@ runs: main: ../dist/pr-comment.js inputs: github-token: - description: "The GitHub token to use for authentication. Defaults to the GitHub-provided token." + description: > + The GitHub token to use for authentication. Defaults to the GitHub-provided token. + To use a custom token, pass it explicitly to this input. required: false default: ${{ github.token }} body: diff --git a/publish/README.md b/publish/README.md index 44269cb8..a8148f2f 100644 --- a/publish/README.md +++ b/publish/README.md @@ -18,13 +18,13 @@ This action publishes packages to npm. -| Inputs | Description | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | -| `github-token` | The GitHub token to use for authentication. Defaults to the GitHub-provided token. | -| `script` | The command to use to publish packages | -| `pack-dir-artifact-id` | Artifact id for packed publish output generated by the pack subaction | -| `create-github-releases` | Whether to create Github releases after publish | -| `push-git-tags` | Whether to create git tags after publish. If `create-github-releases` is set to `true`, this option will also always be `true`. | +| Inputs | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `github-token` | The GitHub token to use for authentication. Defaults to the GitHub-provided token. To use a custom token, pass it explicitly to this input. | +| `script` | The command to use to publish packages | +| `pack-dir-artifact-id` | Artifact id for packed publish output generated by the pack subaction | +| `create-github-releases` | Whether to create Github releases after publish | +| `push-git-tags` | Whether to create git tags after publish. If `create-github-releases` is set to `true`, this option will also always be `true`. | | Outputs | Description | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/publish/action.yml b/publish/action.yml index aba3946b..511fb7aa 100644 --- a/publish/action.yml +++ b/publish/action.yml @@ -5,7 +5,9 @@ runs: main: ../dist/publish.js inputs: github-token: - description: "The GitHub token to use for authentication. Defaults to the GitHub-provided token." + description: > + The GitHub token to use for authentication. Defaults to the GitHub-provided token. + To use a custom token, pass it explicitly to this input. required: false default: ${{ github.token }} script: diff --git a/src/index.ts b/src/index.ts index a9cfc535..14420bf9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { runPublish, runVersion } from "./run.ts"; import { getOptionalInput, getRequiredInput, + throwOnRemovedCommitModeInput, throwOnRenamedInputs, validateChangesetsCliVersion, } from "./utils.ts"; @@ -22,8 +23,8 @@ import { branch: "pr-base-branch", prDraft: "pr-draft", createGithubReleases: "create-github-releases", - commitMode: "push-with-git-cli", }); + throwOnRemovedCommitModeInput(); const githubToken = getRequiredInput("github-token"); if (process.env.GITHUB_TOKEN && process.env.GITHUB_TOKEN !== githubToken) { diff --git a/src/utils.test.ts b/src/utils.test.ts index a2326f31..fe212526 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -1,9 +1,10 @@ import { createFixture } from "fs-fixture"; -import { expect, test } from "vitest"; +import { afterEach, expect, test, vi } from "vitest"; import { BumpLevels, getChangelogEntry, sortTheThings, + throwOnRemovedCommitModeInput, validateChangesetsCliVersion, } from "./utils.ts"; @@ -74,6 +75,10 @@ let changelog = `# @keystone-alpha/email - Update mjml-dependency `; +afterEach(() => { + vi.unstubAllEnvs(); +}); + test("it works", () => { let entry = getChangelogEntry(changelog, "3.0.0"); expect(entry.content).toMatchSnapshot(); @@ -107,6 +112,19 @@ test("it sorts the things right", () => { expect(things.sort(sortTheThings)).toMatchSnapshot(); }); +test.each([ + ["commit-mode", "git-cli", "push-with-git-cli: true"], + ["commit-mode", "github-api", "push-with-git-cli: false"], + ["commitMode", "git-cli", "push-with-git-cli: true"], + ["commitMode", "github-api", "push-with-git-cli: false"], +])( + "explains how to migrate the removed %s input from %s", + (inputName, value, replacement) => { + vi.stubEnv(`INPUT_${inputName.toUpperCase()}`, value); + expect(() => throwOnRemovedCommitModeInput()).toThrow(replacement); + }, +); + test("throws when the project declares Changesets CLI v2", async () => { await using fixture = await createFixture({ "package.json": JSON.stringify({ diff --git a/src/utils.ts b/src/utils.ts index f5349b23..3ea38143 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -146,6 +146,23 @@ export function getRequiredInput(name: string) { return core.getInput(name, { required: true }); } +export function throwOnRemovedCommitModeInput() { + for (const inputName of ["commit-mode", "commitMode"]) { + const value = getOptionalInput(inputName); + if (value === undefined) continue; + + const migration = + value === "git-cli" + ? 'Replace it with "push-with-git-cli: true".' + : value === "github-api" + ? 'Remove it or replace it with "push-with-git-cli: false"; GitHub API pushes are now the default.' + : 'Set "push-with-git-cli" to true for Git CLI pushes or false for GitHub API pushes.'; + throw new Error( + `The "${inputName}" input has been replaced by the boolean "push-with-git-cli" input. ${migration}`, + ); + } +} + export function throwOnRenamedInputs(renames: Record) { const references: Record = {}; diff --git a/src/version/index.ts b/src/version/index.ts index 4768d5fb..bf7d3b7a 100644 --- a/src/version/index.ts +++ b/src/version/index.ts @@ -4,6 +4,7 @@ import { runVersion } from "../run.ts"; import { getOptionalInput, getRequiredInput, + throwOnRemovedCommitModeInput, validateChangesetsCliVersion, } from "../utils.ts"; @@ -17,6 +18,7 @@ async function main() { // If the user needs to change the cwd, set `working-directory` in the step instead const cwd = process.cwd(); await validateChangesetsCliVersion(cwd); + throwOnRemovedCommitModeInput(); const githubToken = getRequiredInput("github-token"); const script = getOptionalInput("script"); diff --git a/version/README.md b/version/README.md index 4c1ff0a1..b2c94578 100644 --- a/version/README.md +++ b/version/README.md @@ -22,15 +22,15 @@ This action versions packages and creates or updates a pull request with the cha -| Inputs | Description | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `github-token` | The GitHub token to use for authentication. Defaults to the GitHub-provided token. | -| `script` | The command to use to version packages | -| `commit-message` | The commit message. Default to `Version Packages` | -| `pr-title` | The pull request title. Default to `Version Packages` | -| `pr-draft` | Controls draft PR behavior. Use 'create' to create new version PRs as draft, or 'always' to also convert existing version PRs back to draft when updating them. | -| `pr-base-branch` | Sets the base branch of the PR. Defaults to `github.ref_name`. | -| `commit-mode` | An enum to specify the commit mode. Use "git-cli" to push changes using the Git CLI, or "github-api" to push changes via the GitHub API. When using "github-api", all commits and tags are signed using GitHub's GPG key and attributed to the user or app who owns the GITHUB_TOKEN. | +| Inputs | Description | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `github-token` | The GitHub token to use for authentication. Defaults to the GitHub-provided token. | +| `script` | The command to use to version packages | +| `commit-message` | The commit message. Default to `Version Packages` | +| `pr-title` | The pull request title. Default to `Version Packages` | +| `pr-draft` | Controls draft PR behavior. Use 'create' to create new version PRs as draft, or 'always' to also convert existing version PRs back to draft when updating them. | +| `pr-base-branch` | Sets the base branch of the PR. Defaults to `github.ref_name`. | +| `push-with-git-cli` | Whether to use the Git CLI instead of the GitHub API to push release commits. Defaults to `false`. When using the GitHub API, commits are signed using GitHub's GPG key and attributed to the user or app that owns the `github-token`. | | Outputs | Description | | ----------- | --------------------------------------------------- | diff --git a/version/action.yml b/version/action.yml index 68e472cb..5b9068e4 100644 --- a/version/action.yml +++ b/version/action.yml @@ -28,6 +28,8 @@ inputs: push-with-git-cli: description: > Whether to use the Git CLI instead of the GitHub API to push release commits. + Defaults to `false`. When using the GitHub API, commits are signed using GitHub's + GPG key and attributed to the user or app that owns the `github-token`. required: false default: false outputs: From cb7d7f097121173cf9ecc064575c62f556981d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Mon, 3 Aug 2026 12:32:09 +0200 Subject: [PATCH 24/24] update one thing --- version/README.md | 2 +- version/action.yml | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/version/README.md b/version/README.md index b2c94578..16ea99e4 100644 --- a/version/README.md +++ b/version/README.md @@ -24,7 +24,7 @@ This action versions packages and creates or updates a pull request with the cha | Inputs | Description | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `github-token` | The GitHub token to use for authentication. Defaults to the GitHub-provided token. | +| `github-token` | The GitHub token to use for authentication. Defaults to the GitHub-provided token. To use a custom token, pass it explicitly to this input. | | `script` | The command to use to version packages | | `commit-message` | The commit message. Default to `Version Packages` | | `pr-title` | The pull request title. Default to `Version Packages` | diff --git a/version/action.yml b/version/action.yml index 5b9068e4..e3dda8af 100644 --- a/version/action.yml +++ b/version/action.yml @@ -5,7 +5,9 @@ runs: main: ../dist/version.js inputs: github-token: - description: "The GitHub token to use for authentication. Defaults to the GitHub-provided token." + description: > + The GitHub token to use for authentication. Defaults to the GitHub-provided token. + To use a custom token, pass it explicitly to this input. required: false default: ${{ github.token }} script: