mirror of
https://github.com/changesets/action
synced 2026-10-26 16:39:56 +08:00
* Experiment with skipping setup-git-user more often * drop setup-git-user * fix tests
494 lines
13 KiB
TypeScript
494 lines
13 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import * as core from "@actions/core";
|
|
import {
|
|
exec,
|
|
getExecOutput,
|
|
type ExecOptions,
|
|
type ExecOutput,
|
|
} from "@actions/exec";
|
|
import { context } from "@actions/github";
|
|
import type { PreState } from "@changesets/types";
|
|
import { type Package, getPackages } from "@manypkg/get-packages";
|
|
import type { GitHub } from "./github.ts";
|
|
import type { Octokit } from "./octokit.ts";
|
|
import readChangesetState from "./readChangesetState.ts";
|
|
import {
|
|
execChangesetsCli,
|
|
getChangedPackages,
|
|
getChangelogEntry,
|
|
getExecOutputChangesetsCli,
|
|
getVersionsByDirectory,
|
|
isErrorWithCode,
|
|
sortTheThings,
|
|
} from "./utils.ts";
|
|
|
|
// GitHub Issues/PRs messages have a max size limit on the
|
|
// message body payload.
|
|
// `body is too long (maximum is 65536 characters)`.
|
|
// To avoid that, we ensure to cap the message to 60k chars.
|
|
const MAX_CHARACTERS_PER_MESSAGE = 60000;
|
|
|
|
const createRelease = async (
|
|
octokit: Octokit,
|
|
{ pkg, tagName }: { pkg: Package; tagName: string },
|
|
) => {
|
|
let changelog;
|
|
try {
|
|
changelog = await fs.readFile(path.join(pkg.dir, "CHANGELOG.md"), "utf8");
|
|
} catch (err) {
|
|
if (isErrorWithCode(err, "ENOENT")) {
|
|
// if we can't find a changelog, the user has probably disabled changelogs
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
let changelogEntry = getChangelogEntry(changelog, pkg.packageJson.version);
|
|
if (!changelogEntry) {
|
|
// we can find a changelog but not the entry for this version
|
|
// if this is true, something has probably gone wrong
|
|
throw new Error(
|
|
`Could not find changelog entry for ${pkg.packageJson.name}@${pkg.packageJson.version}`,
|
|
);
|
|
}
|
|
|
|
await octokit.rest.repos.createRelease({
|
|
name: tagName,
|
|
tag_name: tagName,
|
|
body: changelogEntry.content,
|
|
prerelease: pkg.packageJson.version.includes("-"),
|
|
...context.repo,
|
|
});
|
|
};
|
|
|
|
type PublishOptions = {
|
|
script?: string;
|
|
fromPackDir?: string;
|
|
createGithubReleases: boolean;
|
|
pushGitTags: boolean;
|
|
github: GitHub;
|
|
cwd: string;
|
|
};
|
|
|
|
type PublishedPackage = { name: string; version: string };
|
|
type ChangesetsOutputEvent = {
|
|
type: "git-tag";
|
|
tag: string;
|
|
packageName: string;
|
|
};
|
|
|
|
class ChangesetsOutputReadError extends Error {}
|
|
|
|
type PublishResult =
|
|
| {
|
|
published: true;
|
|
publishedPackages: PublishedPackage[];
|
|
exitCode: number;
|
|
}
|
|
| {
|
|
published: false;
|
|
exitCode: number;
|
|
};
|
|
|
|
function isObject(value: unknown) {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function isChangesetsOutputEvent(
|
|
value: unknown,
|
|
): value is ChangesetsOutputEvent {
|
|
return (
|
|
isObject(value) &&
|
|
"type" in value &&
|
|
value.type === "git-tag" &&
|
|
"tag" in value &&
|
|
typeof value.tag === "string" &&
|
|
"packageName" in value &&
|
|
typeof value.packageName === "string"
|
|
);
|
|
}
|
|
|
|
async function readChangesetsOutput(outputPath: string) {
|
|
let rawOutput: string;
|
|
try {
|
|
rawOutput = await fs.readFile(outputPath, "utf8");
|
|
} catch (err) {
|
|
throw new ChangesetsOutputReadError(
|
|
`Failed to read changesets output at ${outputPath}`,
|
|
{ cause: err },
|
|
);
|
|
}
|
|
|
|
const events: ChangesetsOutputEvent[] = [];
|
|
|
|
let lineStart = 0;
|
|
while (lineStart <= rawOutput.length) {
|
|
let lineEnd = rawOutput.indexOf("\n", lineStart);
|
|
if (lineEnd === -1) {
|
|
lineEnd = rawOutput.length;
|
|
}
|
|
const line = rawOutput.slice(lineStart, lineEnd);
|
|
lineStart = lineEnd + 1;
|
|
|
|
if (/^\s*$/.test(line)) {
|
|
continue;
|
|
}
|
|
|
|
let event: unknown;
|
|
try {
|
|
event = JSON.parse(line);
|
|
} catch (err) {
|
|
throw new Error(`Failed to parse changesets output event: ${line}`, {
|
|
cause: err,
|
|
});
|
|
}
|
|
|
|
if (!isChangesetsOutputEvent(event)) {
|
|
continue;
|
|
}
|
|
|
|
events.push(event);
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
export async function runPublish({
|
|
script,
|
|
fromPackDir,
|
|
github,
|
|
createGithubReleases,
|
|
pushGitTags,
|
|
cwd,
|
|
}: PublishOptions): Promise<PublishResult> {
|
|
const { octokit } = github;
|
|
// Changesets creates annotated tags locally, including when the action pushes those tags through the GitHub API.
|
|
// It might also be important for custom publish scripts to have a valid git user configured.
|
|
await github.ensureGitUser();
|
|
|
|
let changesetPublishOutput: ExecOutput;
|
|
const outputFile = path.join(
|
|
process.env.RUNNER_TEMP ?? (await fs.realpath(os.tmpdir())),
|
|
`changesets-output-${randomUUID()}.ndjson`,
|
|
);
|
|
const execOptions: ExecOptions = {
|
|
cwd,
|
|
ignoreReturnCode: true,
|
|
env: {
|
|
...process.env,
|
|
GITHUB_TOKEN: github.getToken(),
|
|
CHANGESETS_OUTPUT: outputFile,
|
|
},
|
|
};
|
|
|
|
if (script) {
|
|
changesetPublishOutput = await getExecOutput(
|
|
script,
|
|
undefined,
|
|
execOptions,
|
|
);
|
|
} else {
|
|
const args = ["publish"];
|
|
if (fromPackDir) {
|
|
args.push("--from-pack-dir", fromPackDir);
|
|
}
|
|
changesetPublishOutput = await getExecOutputChangesetsCli(
|
|
args,
|
|
execOptions,
|
|
);
|
|
}
|
|
|
|
let { packages, tool } = await getPackages(cwd);
|
|
let packagesByName = new Map(packages.map((x) => [x.packageJson.name, x]));
|
|
let output: ChangesetsOutputEvent[];
|
|
try {
|
|
output = await readChangesetsOutput(outputFile);
|
|
} catch (err) {
|
|
if (!script || !(err instanceof ChangesetsOutputReadError)) {
|
|
throw err;
|
|
}
|
|
core.warning(
|
|
`${err.message}. GitHub releases and git tags cannot be created without this output. Ensure the custom publish script passes CHANGESETS_OUTPUT to the Changesets CLI.`,
|
|
);
|
|
output = [];
|
|
}
|
|
let releases = output.map((event) => {
|
|
let pkg = packagesByName.get(event.packageName);
|
|
if (pkg === undefined) {
|
|
throw new Error(
|
|
`Package "${event.packageName}" not found.` +
|
|
"This is probably a bug in the action, please open an issue",
|
|
);
|
|
}
|
|
return { pkg, tag: event.tag };
|
|
});
|
|
|
|
if (tool.type === "root" && packages.length === 0) {
|
|
throw new Error(
|
|
`No package found.` +
|
|
"This is probably a bug in the action, please open an issue",
|
|
);
|
|
}
|
|
|
|
if (createGithubReleases || pushGitTags) {
|
|
await Promise.all(
|
|
releases.map(async ({ pkg, tag }) => {
|
|
if (pushGitTags) {
|
|
await github.pushTag(tag);
|
|
}
|
|
if (createGithubReleases) {
|
|
await createRelease(octokit, { pkg, tagName: tag });
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (releases.length) {
|
|
return {
|
|
published: true,
|
|
publishedPackages: releases.map(({ pkg }) => ({
|
|
name: pkg.packageJson.name,
|
|
version: pkg.packageJson.version,
|
|
})),
|
|
exitCode: changesetPublishOutput.exitCode,
|
|
};
|
|
}
|
|
|
|
return { published: false, exitCode: changesetPublishOutput.exitCode };
|
|
}
|
|
|
|
type GetMessageOptions = {
|
|
hasPublishScript: boolean;
|
|
branch: string;
|
|
changedPackagesInfo: {
|
|
highestLevel: number;
|
|
private: boolean;
|
|
content: string;
|
|
header: string;
|
|
}[];
|
|
prBodyMaxCharacters: number;
|
|
preState?: PreState;
|
|
};
|
|
|
|
export async function getVersionPrBody({
|
|
hasPublishScript,
|
|
preState,
|
|
changedPackagesInfo,
|
|
prBodyMaxCharacters,
|
|
branch,
|
|
}: GetMessageOptions) {
|
|
let messageHeader = `This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and ${
|
|
hasPublishScript
|
|
? `the packages will be published to npm automatically`
|
|
: `publish to npm yourself or [setup this action to publish automatically](https://github.com/changesets/action#with-publishing)`
|
|
}. If you're not ready to do a release yet, that's fine, whenever you add more changesets to ${branch}, this PR will be updated.
|
|
`;
|
|
let messagePrestate = !!preState
|
|
? `⚠️⚠️⚠️⚠️⚠️⚠️
|
|
|
|
\`${branch}\` is currently in **pre mode** so this branch has prereleases rather than normal releases. If you want to exit prereleases, run \`changeset pre exit\` on \`${branch}\`.
|
|
|
|
⚠️⚠️⚠️⚠️⚠️⚠️
|
|
`
|
|
: "";
|
|
let messageReleasesHeading = `# Releases`;
|
|
|
|
let fullMessage = [
|
|
messageHeader,
|
|
messagePrestate,
|
|
messageReleasesHeading,
|
|
...changedPackagesInfo.map((info) => `${info.header}\n\n${info.content}`),
|
|
].join("\n");
|
|
|
|
// Check that the message does not exceed the size limit.
|
|
// If not, omit the changelog entries of each package.
|
|
if (fullMessage.length > prBodyMaxCharacters) {
|
|
fullMessage = [
|
|
messageHeader,
|
|
messagePrestate,
|
|
messageReleasesHeading,
|
|
`\n> The changelog information of each package has been omitted from this message, as the content exceeds the size limit.\n`,
|
|
...changedPackagesInfo.map((info) => `${info.header}\n\n`),
|
|
].join("\n");
|
|
}
|
|
|
|
// Check (again) that the message is within the size limit.
|
|
// If not, omit all release content this time.
|
|
if (fullMessage.length > prBodyMaxCharacters) {
|
|
fullMessage = [
|
|
messageHeader,
|
|
messagePrestate,
|
|
messageReleasesHeading,
|
|
`\n> All release information have been omitted from this message, as the content exceeds the size limit.`,
|
|
].join("\n");
|
|
}
|
|
|
|
return fullMessage;
|
|
}
|
|
|
|
type VersionOptions = {
|
|
script?: string;
|
|
github: GitHub;
|
|
cwd?: string;
|
|
prTitle?: string;
|
|
commitMessage?: string;
|
|
hasPublishScript?: boolean;
|
|
prBodyMaxCharacters?: number;
|
|
prDraft?: "always" | "create";
|
|
branch?: string;
|
|
};
|
|
|
|
type RunVersionResult = {
|
|
pullRequestNumber: number;
|
|
};
|
|
|
|
export async function runVersion({
|
|
script,
|
|
github,
|
|
cwd = process.cwd(),
|
|
prTitle = "Version Packages",
|
|
commitMessage = "Version Packages",
|
|
hasPublishScript = false,
|
|
prBodyMaxCharacters = MAX_CHARACTERS_PER_MESSAGE,
|
|
branch = context.ref.replace("refs/heads/", ""),
|
|
prDraft,
|
|
}: VersionOptions): Promise<RunVersionResult> {
|
|
const { octokit } = github;
|
|
let versionBranch = `changeset-release/${branch}`;
|
|
|
|
let { preState } = await readChangesetState(cwd);
|
|
|
|
await github.prepareBranch(versionBranch);
|
|
|
|
let versionsByDirectory = await getVersionsByDirectory(cwd);
|
|
|
|
const env = { ...process.env, GITHUB_TOKEN: github.getToken() };
|
|
|
|
if (script) {
|
|
await exec(script, undefined, { cwd, env });
|
|
} else {
|
|
await execChangesetsCli(["version"], { cwd, env });
|
|
}
|
|
|
|
let changedPackages = await getChangedPackages(cwd, versionsByDirectory);
|
|
let changedPackagesInfoPromises = Promise.all(
|
|
changedPackages.map(async (pkg) => {
|
|
let changelogContents = await fs.readFile(
|
|
path.join(pkg.dir, "CHANGELOG.md"),
|
|
"utf8",
|
|
);
|
|
|
|
let entry = getChangelogEntry(changelogContents, pkg.packageJson.version);
|
|
return {
|
|
highestLevel: entry.highestLevel,
|
|
private: !!pkg.packageJson.private,
|
|
content: entry.content,
|
|
header: `## ${pkg.packageJson.name}@${pkg.packageJson.version}`,
|
|
};
|
|
}),
|
|
);
|
|
|
|
const finalPrTitle = `${prTitle}${!!preState ? ` (${preState.tag})` : ""}`;
|
|
const finalCommitMessage = `${commitMessage}${
|
|
!!preState ? ` (${preState.tag})` : ""
|
|
}`;
|
|
|
|
const existingPullRequests = await octokit.rest.pulls.list({
|
|
...context.repo,
|
|
state: "open",
|
|
head: `${context.repo.owner}:${versionBranch}`,
|
|
base: branch,
|
|
});
|
|
core.info(
|
|
`Existing pull requests: ${JSON.stringify(
|
|
existingPullRequests.data,
|
|
null,
|
|
2,
|
|
)}`,
|
|
);
|
|
|
|
await github.pushChanges({
|
|
branch: versionBranch,
|
|
message: finalCommitMessage,
|
|
});
|
|
|
|
const changedPackagesInfo = (await changedPackagesInfoPromises)
|
|
.filter((x) => x)
|
|
.sort(sortTheThings);
|
|
|
|
let prBody = await getVersionPrBody({
|
|
hasPublishScript,
|
|
preState,
|
|
branch,
|
|
changedPackagesInfo,
|
|
prBodyMaxCharacters,
|
|
});
|
|
|
|
if (existingPullRequests.data.length === 0) {
|
|
core.info("creating pull request");
|
|
const { data: newPullRequest } = await octokit.rest.pulls.create({
|
|
base: branch,
|
|
head: versionBranch,
|
|
title: finalPrTitle,
|
|
body: prBody,
|
|
draft: prDraft !== undefined,
|
|
...context.repo,
|
|
});
|
|
|
|
return {
|
|
pullRequestNumber: newPullRequest.number,
|
|
};
|
|
} else {
|
|
const [pullRequest] = existingPullRequests.data;
|
|
|
|
core.info(`updating found pull request #${pullRequest.number}`);
|
|
const convertPullRequestToDraftMutation =
|
|
prDraft === "always"
|
|
? `
|
|
convertPullRequestToDraft(
|
|
input: {
|
|
pullRequestId: $pullRequestId
|
|
}
|
|
) {
|
|
pullRequest {
|
|
id
|
|
}
|
|
}`
|
|
: "";
|
|
const updatePullRequestMutation = `
|
|
mutation UpdatePullRequest(
|
|
$pullRequestId: ID!
|
|
$title: String!
|
|
$body: String!
|
|
) {
|
|
${convertPullRequestToDraftMutation}
|
|
|
|
updatePullRequest(
|
|
input: {
|
|
pullRequestId: $pullRequestId
|
|
title: $title
|
|
body: $body
|
|
state: OPEN
|
|
}
|
|
) {
|
|
pullRequest {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
await octokit.graphql(updatePullRequestMutation, {
|
|
pullRequestId: pullRequest.node_id,
|
|
title: finalPrTitle,
|
|
body: prBody,
|
|
});
|
|
|
|
return {
|
|
pullRequestNumber: pullRequest.number,
|
|
};
|
|
}
|
|
}
|