mirror of
https://github.com/changesets/action
synced 2026-10-26 16:39:56 +08:00
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:
@@ -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
@@ -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<string, string>;
|
||||
@@ -50,20 +48,47 @@ const checkIfClean = async (options: GitOptions): Promise<boolean> => {
|
||||
return !stdout.length;
|
||||
};
|
||||
|
||||
function getHttpUrl(remoteUrl: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(remoteUrl);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Git includes the username when deciding which URL-specific config is
|
||||
// most specific, so retain it. Password, query, and fragment do not
|
||||
// participate in matching; strip them before copying the URL into the env.
|
||||
url.password = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.href;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export class GitHub {
|
||||
readonly #githubToken: string;
|
||||
readonly octokit: Octokit;
|
||||
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<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(
|
||||
"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<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() {
|
||||
@@ -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<string, 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
|
||||
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<string, string>,
|
||||
});
|
||||
}
|
||||
|
||||
+4
-7
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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> = 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(
|
||||
context: PullRequestContext,
|
||||
cwd: string = process.cwd(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ const createGithub = (cwd: string) =>
|
||||
new GitHub({
|
||||
cwd,
|
||||
githubToken: "@@GITHUB_TOKEN",
|
||||
commitMode: "github-api",
|
||||
pushWithGitCli: false,
|
||||
});
|
||||
|
||||
async function initializeGitRepository(cwd: string) {
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
// 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<string, string>) {
|
||||
const references: Record<string, string> = {};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user