diff --git a/.changeset/brave-pandas-commit.md b/.changeset/brave-pandas-commit.md new file mode 100644 index 00000000..08e7e36d --- /dev/null +++ b/.changeset/brave-pandas-commit.md @@ -0,0 +1,9 @@ +--- +"@changesets/action": major +--- + +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 febb4abb..4b724e7b 100644 --- a/README.md +++ b/README.md @@ -36,22 +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`. | -| `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 04fcf068..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: @@ -38,14 +40,13 @@ 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. + 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: "git-cli" + default: false outputs: published: description: A "true" or "false" string value to indicate whether a publishing is happened or not 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/github.test.ts b/src/github.test.ts new file mode 100644 index 00000000..69e9b822 --- /dev/null +++ b/src/github.test.ts @@ -0,0 +1,227 @@ +import { Buffer } from "node:buffer"; +import fs from "node:fs/promises"; +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.ts"; + +const githubContext = vi.hoisted(() => ({ + repo: { + owner: "changesets", + repo: "action", + }, + sha: "base-sha", +})); + +vi.mock("@actions/github", () => ({ + context: githubContext, + getOctokit: () => ({}), +})); + +async function git(cwd: string, args: string[]) { + const result = await exec("git", args, { + nodeOptions: { cwd }, + throwOnError: true, + }); + return result.stdout.trim(); +} + +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"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("GitHub", () => { + it("defaults to GitHub API mode", () => { + const github = new GitHub({ + cwd: "/repo", + githubToken: "token", + }); + + expect(github.pushWithGitCli).toBe(false); + }); + + 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 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", + `http.${serverUrl}/.extraheader`, + `AUTHORIZATION: ${getAuthorization(checkoutToken)}`, + ]); + + const github = await pushChangedFile(repository, serverUrl, actionToken); + await git(repository, ["tag", "v1.0.0"]); + await github.pushTag("v1.0.0"); + + 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"]), + ); + expectRequestsToUseToken(remote, actionToken); + }, 15_000); + + 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 createRemote(); + await using repositoryFixture = await shallowClone(remote.path); + const repository = repositoryFixture.path; + + const remoteUrl = new URL(remote.url); + 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, [ + "config", + `http.${persistedCredentialUrl.href}.extraheader`, + `AUTHORIZATION: ${getAuthorization("checkout-token")}`, + ]); + + await pushChangedFile(repository, remoteUrl.origin, 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 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 pushChangedFile( + repository, + new URL(fetchRemote.url).origin, + actionToken, + ); + + expect(fetchRemote.requests).toEqual([]); + 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 createRemote(); + await using secondRemote = await createRemote(); + await using repositoryFixture = await shallowClone(firstRemote.path); + const repository = repositoryFixture.path; + + for (const remote of [firstRemote, secondRemote]) { + await git(repository, [ + "config", + "--add", + "remote.origin.pushurl", + remote.url, + ]); + } + + await pushChangedFile( + repository, + new URL(firstRemote.url).origin, + actionToken, + ); + + for (const remote of [firstRemote, secondRemote]) { + 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 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]); + vi.stubEnv("GIT_CONFIG_COUNT", "1"); + vi.stubEnv("GIT_CONFIG_KEY_0", "remote.origin.pushurl"); + vi.stubEnv("GIT_CONFIG_VALUE_0", pushRemote.url); + + await pushChangedFile( + repository, + new URL(fetchRemote.url).origin, + actionToken, + ); + + expect(fetchRemote.requests).toEqual([]); + await expectReleaseBranch(pushRemote, repository); + expectRequestsToUseToken(pushRemote, actionToken); + }, 15_000); +}); diff --git a/src/github.ts b/src/github.ts index e6cd51e6..802d3818 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; @@ -50,20 +48,47 @@ 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; readonly cwd: string; - readonly commitMode: CommitMode; + readonly pushWithGitCli: boolean; + readonly serverUrl: string; constructor(options: { githubToken: string; cwd: string; - commitMode?: CommitMode; + pushWithGitCli?: boolean; + serverUrl?: string; }) { this.#githubToken = options.githubToken; this.cwd = options.cwd; - this.commitMode = options.commitMode ?? "git-cli"; + 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); } @@ -71,26 +96,75 @@ export class GitHub { return this.#githubToken; } - #getCliAuthEnv(): Record { + 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", ); - const serverUrl = ( - context.serverUrl ?? - process.env.GITHUB_SERVER_URL ?? - "https://github.com" - ).replace(/\/+$/, ""); + + // 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}`, ); } - return { - GIT_CONFIG_COUNT: String(gitConfigCount + 1), - [`GIT_CONFIG_KEY_${gitConfigCount}`]: `http.${serverUrl}/.extraheader`, - [`GIT_CONFIG_VALUE_${gitConfigCount}`]: `AUTHORIZATION: basic ${basic}`, + + // `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"], + { + cwd: this.cwd, + ignoreReturnCode: true, + // A user-configured remote can contain credentials. + silent: true, + }, + ); + + // 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); + if (httpUrl !== undefined) { + extraHeaderKeys.add(`http.${httpUrl}.extraheader`); + } + } + 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), }; + + // `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; + 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 ensureGitUser() { @@ -139,7 +213,7 @@ export class GitHub { } async pushTag(tag: string) { - if (this.commitMode === "github-api") { + if (!this.pushWithGitCli) { return this.octokit.rest.git .createRef({ ...context.repo, @@ -155,13 +229,13 @@ export class GitHub { cwd: this.cwd, env: { ...process.env, - ...this.#getCliAuthEnv(), + ...(await this.#getCliAuthEnv()), } as Record, }); } 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; } @@ -170,7 +244,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, @@ -191,7 +265,7 @@ export class GitHub { cwd: this.cwd, env: { ...process.env, - ...this.#getCliAuthEnv(), + ...(await this.#getCliAuthEnv()), } as Record, }); } diff --git a/src/index.ts b/src/index.ts index d36485d7..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: "commit-mode", }); + throwOnRemovedCommitModeInput(); const githubToken = getRequiredInput("github-token"); if (process.env.GITHUB_TOKEN && process.env.GITHUB_TOKEN !== githubToken) { @@ -34,12 +35,8 @@ import { ); } - const commitMode = getOptionalInput("commit-mode") ?? "git-cli"; + 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 +44,7 @@ import { const github = new GitHub({ cwd, githubToken, - commitMode, + pushWithGitCli, }); let { changesets } = await readChangesetState(cwd); diff --git a/src/pr-status/worktree.test.ts b/src/pr-status/worktree.test.ts index 1c3e1505..0ca13c70 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.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"]); 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/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/test-utils.ts b/src/test-utils.ts new file mode 100644 index 00000000..dfb834dd --- /dev/null +++ b/src/test-utils.ts @@ -0,0 +1,293 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import fsp from "node:fs/promises"; +import http, { type IncomingMessage, type ServerResponse } from "node:http"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createFixture, type FileTree } from "fs-fixture"; +import { exec } from "tinyexec"; +import { moveDisposable } from "./utils.ts"; + +export type Fixture = FileTree; + +export async function testdir(dir?: Fixture) { + return createFixture(dir, { + fs: { + ...fsp, + rm: (filePath, options) => + fsp.rm(filePath, { + maxRetries: 3, + retryDelay: 100, + ...options, + }), + }, + }); +} + +// Git maintenance can race with fixture cleanup by touching pack files. +export async function disableGitBackgroundMaintenance(cwd: string) { + await exec("git", ["config", "gc.auto", "0"], { + nodeOptions: { cwd }, + throwOnError: true, + }); + await exec("git", ["config", "maintenance.auto", "false"], { + nodeOptions: { cwd }, + throwOnError: true, + }); +} + +export async function gitdir(dir: Fixture) { + await using stack = new AsyncDisposableStack(); + const fixture = stack.use( + await testdir({ + ".gitattributes": "* text=auto eol=lf\n", + ...dir, + }), + ); + const cwd = fixture.path; + + await exec("git", ["init"], { + nodeOptions: { cwd }, + throwOnError: true, + }); + await disableGitBackgroundMaintenance(cwd); + + const { stdout } = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + nodeOptions: { cwd }, + }); + if (stdout.trim() !== "main") { + await exec("git", ["checkout", "-b", "main"], { + nodeOptions: { cwd }, + throwOnError: true, + }); + } + + const gitConfig = ` +[user] + email = x@y.z + name = xyz +[commit] + gpgSign = false +[tag] + gpgSign = false + forceSignAnnotated = false + `.trim(); + await fsp.appendFile(path.join(cwd, ".git/config"), gitConfig, "utf8"); + + await exec("git", ["add", "."], { + nodeOptions: { cwd }, + throwOnError: true, + }); + await exec("git", ["commit", "-m", "initial commit", "--allow-empty"], { + nodeOptions: { cwd }, + throwOnError: true, + }); + + return moveDisposable(stack, fixture); +} + +export async function shallowClone(cwd: string, depth = 1) { + await using stack = new AsyncDisposableStack(); + const fixture = stack.use(await testdir()); + await exec( + "git", + ["clone", "--depth", depth.toString(), pathToFileURL(cwd).toString(), "."], + { + nodeOptions: { cwd: fixture.path }, + throwOnError: true, + }, + ); + await disableGitBackgroundMaintenance(fixture.path); + return moveDisposable(stack, fixture); +} + +async function runGitHttpBackend( + cwd: string, + request: IncomingMessage, + response: ServerResponse, +) { + // `git http-backend` speaks CGI: request metadata goes through environment + // variables, the request body through stdin, and the response through stdout. + const requestUrl = new URL( + request.url ?? "/", + `http://${request.headers.host ?? "localhost"}`, + ); + const env: NodeJS.ProcessEnv = { + ...process.env, + CONTENT_LENGTH: request.headers["content-length"] ?? "0", + GATEWAY_INTERFACE: "CGI/1.1", + GIT_HTTP_EXPORT_ALL: "1", + GIT_PROJECT_ROOT: cwd, + PATH_INFO: decodeURIComponent(requestUrl.pathname), + QUERY_STRING: requestUrl.search.slice(1), + REMOTE_ADDR: request.socket.remoteAddress ?? "", + REQUEST_METHOD: request.method ?? "GET", + SERVER_PROTOCOL: `HTTP/${request.httpVersion}`, + }; + if (request.headers["content-type"] !== undefined) { + env.CONTENT_TYPE = request.headers["content-type"]; + } + + const backend = spawn("git", ["http-backend"], { + env, + stdio: ["pipe", "pipe", "pipe"], + }); + request.pipe(backend.stdin); + + // Buffer stdout so the CGI headers can be separated from the response body. + // Keep stderr only to explain a backend failure. + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + backend.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + backend.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + + const exitCode = await new Promise((resolve, reject) => { + backend.on("error", reject); + backend.on("close", resolve); + }); + if (exitCode !== 0) { + throw new Error( + `git http-backend exited with ${exitCode}: ${Buffer.concat(stderr).toString("utf8")}`, + ); + } + + const output = Buffer.concat(stdout); + // CGI ends its response headers with a blank line; accept CRLF and LF output. + let separator = Buffer.from("\r\n\r\n"); + let headerEnd = output.indexOf(separator); + if (headerEnd === -1) { + separator = Buffer.from("\n\n"); + headerEnd = output.indexOf(separator); + } + if (headerEnd === -1) { + throw new Error("git http-backend returned an invalid CGI response"); + } + + // Translate CGI status and headers, then forward the remaining bytes as body. + let status = 200; + const headers = output.subarray(0, headerEnd).toString("utf8").split(/\r?\n/); + for (const header of headers) { + const separatorIndex = header.indexOf(":"); + if (separatorIndex === -1) continue; + + const name = header.slice(0, separatorIndex); + const value = header.slice(separatorIndex + 1).trim(); + if (name.toLowerCase() === "status") { + status = Number.parseInt(value, 10); + } else { + response.setHeader(name, value); + } + } + + response.writeHead(status); + response.end(output.subarray(headerEnd + separator.length)); +} + +async function listen(server: http.Server) { + const waiter = Promise.withResolvers(); + + server.on("listening", waiter.resolve); + server.on("error", waiter.reject); + + server.listen(0); + + try { + await waiter.promise; + return server; + } finally { + server.off("listening", waiter.resolve); + server.off("error", waiter.reject); + } +} + +type RecordedRequest = { + method: string; + url: string; + headers: NodeJS.Dict; +}; + +function recordRequest(request: IncomingMessage): RecordedRequest { + return { + method: request.method ?? "GET", + url: request.url ?? "/", + headers: request.headersDistinct, + }; +} + +async function createGitHttpServer(cwd: string) { + const requests: RecordedRequest[] = []; + const server = http.createServer((request, response) => { + const recordedRequest = recordRequest(request); + requests.push(recordedRequest); + + void runGitHttpBackend(cwd, request, response).catch((error: unknown) => { + response.destroy( + Error.isError(error) + ? error + : new Error("Server error", { cause: error }), + ); + }); + }); + + await listen(server); + const address = server.address(); + assert( + !!address && typeof address !== "string", + "Failed to get server address", + ); + + return { + origin: `http://127.0.0.1:${address.port}`, + requests, + async [Symbol.asyncDispose]() { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }, + }; +} + +async function createLocalRemote(dir: Fixture) { + await using stack = new AsyncDisposableStack(); + const fixture = stack.use(await testdir()); + const remote = fixture.path; + { + // Use a working repository to create the bare remote's initial history. + // Once cloned, the remote owns that history and the source can be disposed. + await using sourceFixture = await gitdir(dir); + await exec( + "git", + ["clone", "--bare", pathToFileURL(sourceFixture.path).toString(), "."], + { + nodeOptions: { cwd: remote }, + throwOnError: true, + }, + ); + } + await disableGitBackgroundMaintenance(remote); + await exec("git", ["config", "http.receivepack", "true"], { + nodeOptions: { cwd: remote }, + throwOnError: true, + }); + return moveDisposable(stack, fixture); +} + +export async function createGitHttpRemote(files: Fixture) { + await using stack = new AsyncDisposableStack(); + const fixture = stack.use(await createLocalRemote(files)); + const server = stack.use( + await createGitHttpServer(path.dirname(fixture.path)), + ); + + return moveDisposable(stack, { + path: fixture.path, + url: `${server.origin}/${path.basename(fixture.path)}`, + requests: server.requests, + }); +} 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 dfcda7d3..3ea38143 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; @@ -130,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 edf82980..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"); @@ -24,20 +26,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") ?? "git-cli"; + 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/README.md b/version/README.md index 4c1ff0a1..16ea99e4 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. 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` | +| `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 9ba675e1..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: @@ -25,14 +27,13 @@ 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. + 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: "git-cli" + default: false outputs: pr-number: description: The pull request number that was created or updated