Use GitHub API for pushes by default and require explicit github-token when using custom tokens (#692)

* Ensure `github-token` wins for `git-cli` pushes

* more shenanigans

* Default to GitHub API commit mode

* e2e auth tests

* push missing utils

* rename input

* refactor

* refactor

* refactor

* refactor

* refactor

* refactor

* reuse helpers

* refactor

* refactor

* simplify

* add tests

* refactor

* improve comments

* shift helper

* add comments

* extract test helpers

* update docs and changesets

* update one thing
This commit is contained in:
Mateusz Burzyński
2026-08-03 11:02:31 +00:00
committed by GitHub
parent 50de20a107
commit cb3f0110d7
20 changed files with 766 additions and 129 deletions
+9
View File
@@ -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.
+13 -3
View File
@@ -36,13 +36,23 @@ If using [trusted publishing](https://docs.npmjs.com/trusted-publishers), it's r
> [!TIP] > [!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. > 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 ### API
<!-- api-start --> <!-- api-start -->
| Inputs | Description | | 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. |
| `publish-script` | The command to use to build and publish packages | | `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 | | `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` | | `commit-message` | The commit message. Default to `Version Packages` |
@@ -51,7 +61,7 @@ If using [trusted publishing](https://docs.npmjs.com/trusted-publishers), it's r
| `pr-base-branch` | Sets the base branch of the PR. Defaults to `github.ref_name`. | | `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 | | `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-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. | | `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 | | Outputs | Description |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
+8 -7
View File
@@ -5,7 +5,9 @@ runs:
main: "dist/index.js" main: "dist/index.js"
inputs: inputs:
github-token: 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 required: false
default: ${{ github.token }} default: ${{ github.token }}
publish-script: publish-script:
@@ -38,14 +40,13 @@ inputs:
this option will also always be `true`. this option will also always be `true`.
required: false required: false
default: true default: true
commit-mode: push-with-git-cli:
description: > description: >
An enum to specify the commit mode. Use "git-cli" to push changes using the Git CLI, Whether to use the Git CLI instead of the GitHub API to push release commits and tags.
or "github-api" to push changes via the GitHub API. When using "github-api", Defaults to `false`. When using the GitHub API, commits and tags are signed using
all commits and tags are signed using GitHub's GPG key and attributed to the user GitHub's GPG key and attributed to the user or app that owns the `github-token`.
or app who owns the GITHUB_TOKEN.
required: false required: false
default: "git-cli" default: false
outputs: outputs:
published: published:
description: A "true" or "false" string value to indicate whether a publishing is happened or not description: A "true" or "false" string value to indicate whether a publishing is happened or not
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
| Inputs | Description | | 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. | | `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. | | `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. |
+3 -1
View File
@@ -5,7 +5,9 @@ runs:
main: ../dist/pr-comment.js main: ../dist/pr-comment.js
inputs: inputs:
github-token: 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 required: false
default: ${{ github.token }} default: ${{ github.token }}
body: body:
+2 -2
View File
@@ -19,8 +19,8 @@ This action publishes packages to npm.
<!-- api-start --> <!-- api-start -->
| Inputs | Description | | 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 publish packages | | `script` | The command to use to publish packages |
| `pack-dir-artifact-id` | Artifact id for packed publish output generated by the pack subaction | | `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 | | `create-github-releases` | Whether to create Github releases after publish |
+3 -1
View File
@@ -5,7 +5,9 @@ runs:
main: ../dist/publish.js main: ../dist/publish.js
inputs: inputs:
github-token: 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 required: false
default: ${{ github.token }} default: ${{ github.token }}
script: script:
+227
View File
@@ -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<ReturnType<typeof createGitHttpRemote>>;
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);
});
+94 -20
View File
@@ -5,8 +5,6 @@ import { context } from "@actions/github";
import { commitChangesSinceBase } from "@changesets/ghcommit"; import { commitChangesSinceBase } from "@changesets/ghcommit";
import { setupOctokit, type Octokit } from "./octokit.ts"; import { setupOctokit, type Octokit } from "./octokit.ts";
export type CommitMode = "git-cli" | "github-api";
type GitOptions = { type GitOptions = {
cwd: string; cwd: string;
env?: Record<string, string>; env?: Record<string, string>;
@@ -50,20 +48,47 @@ const checkIfClean = async (options: GitOptions): Promise<boolean> => {
return !stdout.length; 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 { export class GitHub {
readonly #githubToken: string; readonly #githubToken: string;
readonly octokit: Octokit; readonly octokit: Octokit;
readonly cwd: string; readonly cwd: string;
readonly commitMode: CommitMode; readonly pushWithGitCli: boolean;
readonly serverUrl: string;
constructor(options: { constructor(options: {
githubToken: string; githubToken: string;
cwd: string; cwd: string;
commitMode?: CommitMode; pushWithGitCli?: boolean;
serverUrl?: string;
}) { }) {
this.#githubToken = options.githubToken; this.#githubToken = options.githubToken;
this.cwd = options.cwd; 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); this.octokit = setupOctokit(options.githubToken);
} }
@@ -71,26 +96,75 @@ export class GitHub {
return this.#githubToken; return this.#githubToken;
} }
#getCliAuthEnv(): Record<string, string> { async #getCliAuthEnv(): Promise<Record<string, string>> {
// 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( const basic = Buffer.from(`x-access-token:${this.#githubToken}`).toString(
"base64", "base64",
); );
const serverUrl = (
context.serverUrl ?? // The environment may already contain command-scoped Git config. Append
process.env.GITHUB_SERVER_URL ?? // our KEY_n/VALUE_n entries so those existing settings stay active.
"https://github.com"
).replace(/\/+$/, "");
const gitConfigCount = Number(process.env.GIT_CONFIG_COUNT ?? 0); const gitConfigCount = Number(process.env.GIT_CONFIG_COUNT ?? 0);
if (!Number.isInteger(gitConfigCount) || gitConfigCount < 0) { if (!Number.isInteger(gitConfigCount) || gitConfigCount < 0) {
throw new Error( throw new Error(
`Invalid GIT_CONFIG_COUNT value: ${process.env.GIT_CONFIG_COUNT}`, `Invalid GIT_CONFIG_COUNT value: ${process.env.GIT_CONFIG_COUNT}`,
); );
} }
return {
GIT_CONFIG_COUNT: String(gitConfigCount + 1), // `git push origin` prefers remote.origin.pushurl over the fetch URL and
[`GIT_CONFIG_KEY_${gitConfigCount}`]: `http.${serverUrl}/.extraheader`, // supports more than one push URL, so inspect every effective destination.
[`GIT_CONFIG_VALUE_${gitConfigCount}`]: `AUTHORIZATION: basic ${basic}`, 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<string, string> = {
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() { async ensureGitUser() {
@@ -139,7 +213,7 @@ export class GitHub {
} }
async pushTag(tag: string) { async pushTag(tag: string) {
if (this.commitMode === "github-api") { if (!this.pushWithGitCli) {
return this.octokit.rest.git return this.octokit.rest.git
.createRef({ .createRef({
...context.repo, ...context.repo,
@@ -155,13 +229,13 @@ export class GitHub {
cwd: this.cwd, cwd: this.cwd,
env: { env: {
...process.env, ...process.env,
...this.#getCliAuthEnv(), ...(await this.#getCliAuthEnv()),
} as Record<string, string>, } as Record<string, string>,
}); });
} }
async prepareBranch(branch: string) { async prepareBranch(branch: string) {
if (this.commitMode === "github-api") { if (!this.pushWithGitCli) {
// Preparing a new local branch is not necessary when using the API // Preparing a new local branch is not necessary when using the API
return; return;
} }
@@ -170,7 +244,7 @@ export class GitHub {
} }
async pushChanges({ branch, message }: { branch: string; message: string }) { async pushChanges({ branch, message }: { branch: string; message: string }) {
if (this.commitMode === "github-api") { if (!this.pushWithGitCli) {
await commitChangesSinceBase({ await commitChangesSinceBase({
octokit: this.octokit, octokit: this.octokit,
...context.repo, ...context.repo,
@@ -191,7 +265,7 @@ export class GitHub {
cwd: this.cwd, cwd: this.cwd,
env: { env: {
...process.env, ...process.env,
...this.#getCliAuthEnv(), ...(await this.#getCliAuthEnv()),
} as Record<string, string>, } as Record<string, string>,
}); });
} }
+4 -7
View File
@@ -5,6 +5,7 @@ import { runPublish, runVersion } from "./run.ts";
import { import {
getOptionalInput, getOptionalInput,
getRequiredInput, getRequiredInput,
throwOnRemovedCommitModeInput,
throwOnRenamedInputs, throwOnRenamedInputs,
validateChangesetsCliVersion, validateChangesetsCliVersion,
} from "./utils.ts"; } from "./utils.ts";
@@ -22,8 +23,8 @@ import {
branch: "pr-base-branch", branch: "pr-base-branch",
prDraft: "pr-draft", prDraft: "pr-draft",
createGithubReleases: "create-github-releases", createGithubReleases: "create-github-releases",
commitMode: "commit-mode",
}); });
throwOnRemovedCommitModeInput();
const githubToken = getRequiredInput("github-token"); const githubToken = getRequiredInput("github-token");
if (process.env.GITHUB_TOKEN && process.env.GITHUB_TOKEN !== githubToken) { 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"); 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") { if (prDraft !== undefined && prDraft !== "always" && prDraft !== "create") {
core.setFailed(`Invalid pr-draft: ${prDraft}`); core.setFailed(`Invalid pr-draft: ${prDraft}`);
return; return;
@@ -47,7 +44,7 @@ import {
const github = new GitHub({ const github = new GitHub({
cwd, cwd,
githubToken, githubToken,
commitMode, pushWithGitCli,
}); });
let { changesets } = await readChangesetState(cwd); let { changesets } = await readChangesetState(cwd);
+18 -31
View File
@@ -1,9 +1,9 @@
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import type * as github from "@actions/github"; import type * as github from "@actions/github";
import { getReleasePlan } from "@changesets/get-release-plan"; import { getReleasePlan } from "@changesets/get-release-plan";
import { createFixture } from "fs-fixture";
import { exec } from "tinyexec"; import { exec } from "tinyexec";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { gitdir, shallowClone, testdir } from "../test-utils.ts";
import { getPullRequestWorktree } from "./worktree.ts"; import { getPullRequestWorktree } from "./worktree.ts";
type PullRequestContext = NonNullable< type PullRequestContext = NonNullable<
@@ -21,7 +21,7 @@ async function git(cwd: string, args: string[]) {
describe("getPullRequestWorktree", () => { describe("getPullRequestWorktree", () => {
it("fetches a PR branch into a detached worktree and keeps the main checkout untouched", async () => { it("fetches a PR branch into a detached worktree and keeps the main checkout untouched", async () => {
// Local source repo // Local source repo
await using sourceRepoFixture = await createFixture({ await using sourceRepoFixture = await gitdir({
".changeset/config.json": JSON.stringify({}), ".changeset/config.json": JSON.stringify({}),
"package.json": JSON.stringify({ "package.json": JSON.stringify({
name: "repo", name: "repo",
@@ -35,46 +35,33 @@ describe("getPullRequestWorktree", () => {
}), }),
}); });
const sourceRepo = sourceRepoFixture.path; 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 // Simulate remote bare git server
await using originBareFixture = await createFixture(); await using originBareFixture = await testdir();
const originBare = originBareFixture.path; 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 // Simulate checkout PR in github action
await using checkoutRepoFixture = await createFixture(); await using checkoutRepoFixture = await shallowClone(originBare);
const checkoutRepo = checkoutRepoFixture.path; const checkoutRepo = checkoutRepoFixture.path;
await git(checkoutRepo, [
"clone",
"--depth",
"1",
"--branch",
"main",
pathToFileURL(originBare).toString(),
checkoutRepo,
]);
// Simulate remote fork bare git server // Simulate remote fork bare git server
await using forkBareFixture = await createFixture(); await using forkBareFixture = await testdir();
const forkBare = forkBareFixture.path; const forkBare = forkBareFixture.path;
await git(forkBare, ["clone", "--bare", originBare, forkBare]); await git(forkBare, [
await using forkRepoFixture = await createFixture();
const forkRepo = forkRepoFixture.path;
await git(forkRepo, [
"clone", "clone",
"--depth", "--bare",
"1", pathToFileURL(originBare).toString(),
"--branch", ".",
"main",
pathToFileURL(forkBare).toString(),
forkRepo,
]); ]);
await using forkRepoFixture = await shallowClone(forkBare);
const forkRepo = forkRepoFixture.path;
await git(forkRepo, ["config", "user.name", "Test User"]); await git(forkRepo, ["config", "user.name", "Test User"]);
await git(forkRepo, ["config", "user.email", "test@example.com"]); await git(forkRepo, ["config", "user.email", "test@example.com"]);
await git(forkRepo, ["checkout", "-b", "feature"]); await git(forkRepo, ["checkout", "-b", "feature"]);
+1 -16
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import type * as github from "@actions/github"; import type * as github from "@actions/github";
import { isRepoShallow } from "@changesets/git"; import { isRepoShallow } from "@changesets/git";
import { exec } from "tinyexec"; import { exec } from "tinyexec";
import { moveDisposable, type WithAsyncDispose } from "../utils.ts";
type PullRequestContext = NonNullable< type PullRequestContext = NonNullable<
typeof github.context.payload.pull_request typeof github.context.payload.pull_request
@@ -122,22 +123,6 @@ async function tempWorktree(cwd: string, dir: string, ref: Ref) {
}; };
} }
type WithAsyncDispose<T> = T & {
[Symbol.asyncDispose](): Promise<void>;
};
function moveDisposable<T extends object>(
stack: AsyncDisposableStack,
value: T,
): WithAsyncDispose<T> {
const moved = stack.move();
return Object.assign(value, {
async [Symbol.asyncDispose]() {
await moved.disposeAsync();
},
});
}
export async function getPullRequestWorktree( export async function getPullRequestWorktree(
context: PullRequestContext, context: PullRequestContext,
cwd: string = process.cwd(), cwd: string = process.cwd(),
+2 -2
View File
@@ -35,8 +35,8 @@ async function main() {
); );
} }
// NOTE: Always use API mode here as publish does not need a commit-mode. // The publish sub-action always uses the GitHub API for tag pushes.
const github = new GitHub({ cwd, githubToken, commitMode: "github-api" }); const github = new GitHub({ cwd, githubToken });
const fromPackDir = packDirArtifactId const fromPackDir = packDirArtifactId
? await downloadArtifact( ? await downloadArtifact(
+1 -1
View File
@@ -99,7 +99,7 @@ const createGithub = (cwd: string) =>
new GitHub({ new GitHub({
cwd, cwd,
githubToken: "@@GITHUB_TOKEN", githubToken: "@@GITHUB_TOKEN",
commitMode: "github-api", pushWithGitCli: false,
}); });
async function initializeGitRepository(cwd: string) { async function initializeGitRepository(cwd: string) {
+293
View File
@@ -0,0 +1,293 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import fsp from "node:fs/promises";
import http, { type IncomingMessage, type ServerResponse } from "node:http";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createFixture, type FileTree } from "fs-fixture";
import { exec } from "tinyexec";
import { moveDisposable } from "./utils.ts";
export type Fixture = FileTree;
export async function testdir(dir?: Fixture) {
return createFixture(dir, {
fs: {
...fsp,
rm: (filePath, options) =>
fsp.rm(filePath, {
maxRetries: 3,
retryDelay: 100,
...options,
}),
},
});
}
// Git maintenance can race with fixture cleanup by touching pack files.
export async function disableGitBackgroundMaintenance(cwd: string) {
await exec("git", ["config", "gc.auto", "0"], {
nodeOptions: { cwd },
throwOnError: true,
});
await exec("git", ["config", "maintenance.auto", "false"], {
nodeOptions: { cwd },
throwOnError: true,
});
}
export async function gitdir(dir: Fixture) {
await using stack = new AsyncDisposableStack();
const fixture = stack.use(
await testdir({
".gitattributes": "* text=auto eol=lf\n",
...dir,
}),
);
const cwd = fixture.path;
await exec("git", ["init"], {
nodeOptions: { cwd },
throwOnError: true,
});
await disableGitBackgroundMaintenance(cwd);
const { stdout } = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
nodeOptions: { cwd },
});
if (stdout.trim() !== "main") {
await exec("git", ["checkout", "-b", "main"], {
nodeOptions: { cwd },
throwOnError: true,
});
}
const gitConfig = `
[user]
email = x@y.z
name = xyz
[commit]
gpgSign = false
[tag]
gpgSign = false
forceSignAnnotated = false
`.trim();
await fsp.appendFile(path.join(cwd, ".git/config"), gitConfig, "utf8");
await exec("git", ["add", "."], {
nodeOptions: { cwd },
throwOnError: true,
});
await exec("git", ["commit", "-m", "initial commit", "--allow-empty"], {
nodeOptions: { cwd },
throwOnError: true,
});
return moveDisposable(stack, fixture);
}
export async function shallowClone(cwd: string, depth = 1) {
await using stack = new AsyncDisposableStack();
const fixture = stack.use(await testdir());
await exec(
"git",
["clone", "--depth", depth.toString(), pathToFileURL(cwd).toString(), "."],
{
nodeOptions: { cwd: fixture.path },
throwOnError: true,
},
);
await disableGitBackgroundMaintenance(fixture.path);
return moveDisposable(stack, fixture);
}
async function runGitHttpBackend(
cwd: string,
request: IncomingMessage,
response: ServerResponse,
) {
// `git http-backend` speaks CGI: request metadata goes through environment
// variables, the request body through stdin, and the response through stdout.
const requestUrl = new URL(
request.url ?? "/",
`http://${request.headers.host ?? "localhost"}`,
);
const env: NodeJS.ProcessEnv = {
...process.env,
CONTENT_LENGTH: request.headers["content-length"] ?? "0",
GATEWAY_INTERFACE: "CGI/1.1",
GIT_HTTP_EXPORT_ALL: "1",
GIT_PROJECT_ROOT: cwd,
PATH_INFO: decodeURIComponent(requestUrl.pathname),
QUERY_STRING: requestUrl.search.slice(1),
REMOTE_ADDR: request.socket.remoteAddress ?? "",
REQUEST_METHOD: request.method ?? "GET",
SERVER_PROTOCOL: `HTTP/${request.httpVersion}`,
};
if (request.headers["content-type"] !== undefined) {
env.CONTENT_TYPE = request.headers["content-type"];
}
const backend = spawn("git", ["http-backend"], {
env,
stdio: ["pipe", "pipe", "pipe"],
});
request.pipe(backend.stdin);
// Buffer stdout so the CGI headers can be separated from the response body.
// Keep stderr only to explain a backend failure.
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
backend.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
backend.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
const exitCode = await new Promise<number | null>((resolve, reject) => {
backend.on("error", reject);
backend.on("close", resolve);
});
if (exitCode !== 0) {
throw new Error(
`git http-backend exited with ${exitCode}: ${Buffer.concat(stderr).toString("utf8")}`,
);
}
const output = Buffer.concat(stdout);
// CGI ends its response headers with a blank line; accept CRLF and LF output.
let separator = Buffer.from("\r\n\r\n");
let headerEnd = output.indexOf(separator);
if (headerEnd === -1) {
separator = Buffer.from("\n\n");
headerEnd = output.indexOf(separator);
}
if (headerEnd === -1) {
throw new Error("git http-backend returned an invalid CGI response");
}
// Translate CGI status and headers, then forward the remaining bytes as body.
let status = 200;
const headers = output.subarray(0, headerEnd).toString("utf8").split(/\r?\n/);
for (const header of headers) {
const separatorIndex = header.indexOf(":");
if (separatorIndex === -1) continue;
const name = header.slice(0, separatorIndex);
const value = header.slice(separatorIndex + 1).trim();
if (name.toLowerCase() === "status") {
status = Number.parseInt(value, 10);
} else {
response.setHeader(name, value);
}
}
response.writeHead(status);
response.end(output.subarray(headerEnd + separator.length));
}
async function listen(server: http.Server) {
const waiter = Promise.withResolvers();
server.on("listening", waiter.resolve);
server.on("error", waiter.reject);
server.listen(0);
try {
await waiter.promise;
return server;
} finally {
server.off("listening", waiter.resolve);
server.off("error", waiter.reject);
}
}
type RecordedRequest = {
method: string;
url: string;
headers: NodeJS.Dict<string[]>;
};
function recordRequest(request: IncomingMessage): RecordedRequest {
return {
method: request.method ?? "GET",
url: request.url ?? "/",
headers: request.headersDistinct,
};
}
async function createGitHttpServer(cwd: string) {
const requests: RecordedRequest[] = [];
const server = http.createServer((request, response) => {
const recordedRequest = recordRequest(request);
requests.push(recordedRequest);
void runGitHttpBackend(cwd, request, response).catch((error: unknown) => {
response.destroy(
Error.isError(error)
? error
: new Error("Server error", { cause: error }),
);
});
});
await listen(server);
const address = server.address();
assert(
!!address && typeof address !== "string",
"Failed to get server address",
);
return {
origin: `http://127.0.0.1:${address.port}`,
requests,
async [Symbol.asyncDispose]() {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
},
};
}
async function createLocalRemote(dir: Fixture) {
await using stack = new AsyncDisposableStack();
const fixture = stack.use(await testdir());
const remote = fixture.path;
{
// Use a working repository to create the bare remote's initial history.
// Once cloned, the remote owns that history and the source can be disposed.
await using sourceFixture = await gitdir(dir);
await exec(
"git",
["clone", "--bare", pathToFileURL(sourceFixture.path).toString(), "."],
{
nodeOptions: { cwd: remote },
throwOnError: true,
},
);
}
await disableGitBackgroundMaintenance(remote);
await exec("git", ["config", "http.receivepack", "true"], {
nodeOptions: { cwd: remote },
throwOnError: true,
});
return moveDisposable(stack, fixture);
}
export async function createGitHttpRemote(files: Fixture) {
await using stack = new AsyncDisposableStack();
const fixture = stack.use(await createLocalRemote(files));
const server = stack.use(
await createGitHttpServer(path.dirname(fixture.path)),
);
return moveDisposable(stack, {
path: fixture.path,
url: `${server.origin}/${path.basename(fixture.path)}`,
requests: server.requests,
});
}
+19 -1
View File
@@ -1,9 +1,10 @@
import { createFixture } from "fs-fixture"; import { createFixture } from "fs-fixture";
import { expect, test } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import { import {
BumpLevels, BumpLevels,
getChangelogEntry, getChangelogEntry,
sortTheThings, sortTheThings,
throwOnRemovedCommitModeInput,
validateChangesetsCliVersion, validateChangesetsCliVersion,
} from "./utils.ts"; } from "./utils.ts";
@@ -74,6 +75,10 @@ let changelog = `# @keystone-alpha/email
- Update mjml-dependency - Update mjml-dependency
`; `;
afterEach(() => {
vi.unstubAllEnvs();
});
test("it works", () => { test("it works", () => {
let entry = getChangelogEntry(changelog, "3.0.0"); let entry = getChangelogEntry(changelog, "3.0.0");
expect(entry.content).toMatchSnapshot(); expect(entry.content).toMatchSnapshot();
@@ -107,6 +112,19 @@ test("it sorts the things right", () => {
expect(things.sort(sortTheThings)).toMatchSnapshot(); 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 () => { test("throws when the project declares Changesets CLI v2", async () => {
await using fixture = await createFixture({ await using fixture = await createFixture({
"package.json": JSON.stringify({ "package.json": JSON.stringify({
+33
View File
@@ -119,6 +119,22 @@ export function isErrorWithCode(err: unknown, code: string) {
); );
} }
export type WithAsyncDispose<T> = T & {
[Symbol.asyncDispose](): Promise<void>;
};
export function moveDisposable<T extends object>(
stack: AsyncDisposableStack,
value: T,
): WithAsyncDispose<T> {
const moved = stack.move();
return Object.assign(value, {
async [Symbol.asyncDispose]() {
await moved.disposeAsync();
},
});
}
export function getOptionalInput(name: string) { export function getOptionalInput(name: string) {
// normalize empty string default return value of `core.getInput` to undefined // normalize empty string default return value of `core.getInput` to undefined
return core.getInput(name) || undefined; return core.getInput(name) || undefined;
@@ -130,6 +146,23 @@ export function getRequiredInput(name: string) {
return core.getInput(name, { required: true }); 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<string, string>) { export function throwOnRenamedInputs(renames: Record<string, string>) {
const references: Record<string, string> = {}; const references: Record<string, string> = {};
+4 -6
View File
@@ -4,6 +4,7 @@ import { runVersion } from "../run.ts";
import { import {
getOptionalInput, getOptionalInput,
getRequiredInput, getRequiredInput,
throwOnRemovedCommitModeInput,
validateChangesetsCliVersion, validateChangesetsCliVersion,
} from "../utils.ts"; } 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 // If the user needs to change the cwd, set `working-directory` in the step instead
const cwd = process.cwd(); const cwd = process.cwd();
await validateChangesetsCliVersion(cwd); await validateChangesetsCliVersion(cwd);
throwOnRemovedCommitModeInput();
const githubToken = getRequiredInput("github-token"); const githubToken = getRequiredInput("github-token");
const script = getOptionalInput("script"); const script = getOptionalInput("script");
@@ -24,20 +26,16 @@ async function main() {
const prTitle = getRequiredInput("pr-title"); const prTitle = getRequiredInput("pr-title");
const prDraft = getOptionalInput("pr-draft"); const prDraft = getOptionalInput("pr-draft");
const prBaseBranch = getOptionalInput("pr-base-branch"); const prBaseBranch = getOptionalInput("pr-base-branch");
const commitMode = getOptionalInput("commit-mode") ?? "git-cli"; const pushWithGitCli = core.getBooleanInput("push-with-git-cli");
// Validations // Validations
if (prDraft !== undefined && prDraft !== "always" && prDraft !== "create") { if (prDraft !== undefined && prDraft !== "always" && prDraft !== "create") {
throw new Error(`Invalid pr-draft input: ${prDraft}`); 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({ const github = new GitHub({
cwd, cwd,
githubToken, githubToken,
commitMode, pushWithGitCli,
}); });
const { pullRequestNumber } = await runVersion({ const { pullRequestNumber } = await runVersion({
+3 -3
View File
@@ -23,14 +23,14 @@ This action versions packages and creates or updates a pull request with the cha
<!-- api-start --> <!-- api-start -->
| Inputs | Description | | 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 | | `script` | The command to use to version packages |
| `commit-message` | The commit message. Default to `Version Packages` | | `commit-message` | The commit message. Default to `Version Packages` |
| `pr-title` | The pull request title. 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-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`. | | `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. | | `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 | | Outputs | Description |
| ----------- | --------------------------------------------------- | | ----------- | --------------------------------------------------- |
+8 -7
View File
@@ -5,7 +5,9 @@ runs:
main: ../dist/version.js main: ../dist/version.js
inputs: inputs:
github-token: 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 required: false
default: ${{ github.token }} default: ${{ github.token }}
script: script:
@@ -25,14 +27,13 @@ inputs:
pr-base-branch: pr-base-branch:
description: "Sets the base branch of the PR. Defaults to `github.ref_name`." description: "Sets the base branch of the PR. Defaults to `github.ref_name`."
required: false required: false
commit-mode: push-with-git-cli:
description: > description: >
An enum to specify the commit mode. Use "git-cli" to push changes using the Git CLI, Whether to use the Git CLI instead of the GitHub API to push release commits.
or "github-api" to push changes via the GitHub API. When using "github-api", Defaults to `false`. When using the GitHub API, commits are signed using GitHub's
all commits and tags are signed using GitHub's GPG key and attributed to the user GPG key and attributed to the user or app that owns the `github-token`.
or app who owns the GITHUB_TOKEN.
required: false required: false
default: "git-cli" default: false
outputs: outputs:
pr-number: pr-number:
description: The pull request number that was created or updated description: The pull request number that was created or updated