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,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.
|
||||||
@@ -36,22 +36,32 @@ 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` |
|
||||||
| `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`. |
|
||||||
| `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
@@ -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
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+7
-7
@@ -18,13 +18,13 @@ 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 |
|
||||||
| `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`. |
|
||||||
|
|
||||||
| Outputs | Description |
|
| Outputs | Description |
|
||||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
|||||||
+3
-1
@@ -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:
|
||||||
|
|||||||
@@ -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 { 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
@@ -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);
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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 { 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({
|
||||||
|
|||||||
@@ -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 +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({
|
||||||
|
|||||||
+9
-9
@@ -22,15 +22,15 @@ 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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user