291 lines
10 KiB
JavaScript
291 lines
10 KiB
JavaScript
import { createRequire } from "node:module";
|
||
import { pick } from "lodash-es";
|
||
import * as marked from "marked";
|
||
import envCi from "env-ci";
|
||
import { hookStd } from "hook-std";
|
||
import semver from "semver";
|
||
import AggregateError from "aggregate-error";
|
||
import hideSensitive from "./lib/hide-sensitive.js";
|
||
import getConfig from "./lib/get-config.js";
|
||
import verify from "./lib/verify.js";
|
||
import getNextVersion from "./lib/get-next-version.js";
|
||
import getCommits from "./lib/get-commits.js";
|
||
import getLastRelease from "./lib/get-last-release.js";
|
||
import getReleaseToAdd from "./lib/get-release-to-add.js";
|
||
import { extractErrors, makeTag } from "./lib/utils.js";
|
||
import getGitAuthUrl from "./lib/get-git-auth-url.js";
|
||
import getBranches from "./lib/branches/index.js";
|
||
import getLogger from "./lib/get-logger.js";
|
||
import { addNote, getGitHead, getTagHead, isBranchUpToDate, push, pushNotes, tag, verifyAuth } from "./lib/git.js";
|
||
import getError from "./lib/get-error.js";
|
||
import { COMMIT_EMAIL, COMMIT_NAME } from "./lib/definitions/constants.js";
|
||
|
||
const require = createRequire(import.meta.url);
|
||
const pkg = require("./package.json");
|
||
|
||
let markedOptionsSet = false;
|
||
async function terminalOutput(text) {
|
||
if (!markedOptionsSet) {
|
||
const { default: TerminalRenderer } = await import("marked-terminal"); // eslint-disable-line node/no-unsupported-features/es-syntax
|
||
marked.setOptions({ renderer: new TerminalRenderer() });
|
||
markedOptionsSet = true;
|
||
}
|
||
|
||
return marked.parse(text);
|
||
}
|
||
|
||
/* eslint complexity: off */
|
||
async function run(context, plugins) {
|
||
const { cwd, env, options, logger, envCi } = context;
|
||
const { isCi, branch, prBranch, isPr } = envCi;
|
||
const ciBranch = isPr ? prBranch : branch;
|
||
|
||
if (!isCi && !options.dryRun && !options.noCi) {
|
||
logger.warn("This run was not triggered in a known CI environment, running in dry-run mode.");
|
||
options.dryRun = true;
|
||
} else {
|
||
// When running on CI, set the commits author and committer info and prevent the `git` CLI to prompt for username/password. See #703.
|
||
Object.assign(env, {
|
||
GIT_AUTHOR_NAME: COMMIT_NAME,
|
||
GIT_AUTHOR_EMAIL: COMMIT_EMAIL,
|
||
GIT_COMMITTER_NAME: COMMIT_NAME,
|
||
GIT_COMMITTER_EMAIL: COMMIT_EMAIL,
|
||
...env,
|
||
GIT_ASKPASS: "echo",
|
||
GIT_TERMINAL_PROMPT: 0,
|
||
});
|
||
}
|
||
|
||
if (isCi && isPr && !options.noCi) {
|
||
logger.log("This run was triggered by a pull request and therefore a new version won't be published.");
|
||
return false;
|
||
}
|
||
|
||
// Verify config
|
||
await verify(context);
|
||
|
||
options.repositoryUrl = await getGitAuthUrl({ ...context, branch: { name: ciBranch } });
|
||
context.branches = await getBranches(options.repositoryUrl, ciBranch, context);
|
||
context.branch = context.branches.find(({ name }) => name === ciBranch);
|
||
|
||
if (!context.branch) {
|
||
logger.log(
|
||
`This test run was triggered on the branch ${ciBranch}, while semantic-release is configured to only publish from ${context.branches
|
||
.map(({ name }) => name)
|
||
.join(", ")}, therefore a new version won’t be published.`
|
||
);
|
||
return false;
|
||
}
|
||
|
||
logger[options.dryRun ? "warn" : "success"](
|
||
`Run automated release from branch ${ciBranch} on repository ${options.originalRepositoryURL}${
|
||
options.dryRun ? " in dry-run mode" : ""
|
||
}`
|
||
);
|
||
|
||
try {
|
||
try {
|
||
await verifyAuth(options.repositoryUrl, context.branch.name, { cwd, env });
|
||
} catch (error) {
|
||
if (!(await isBranchUpToDate(options.repositoryUrl, context.branch.name, { cwd, env }))) {
|
||
logger.log(
|
||
`The local branch ${context.branch.name} is behind the remote one, therefore a new version won't be published.`
|
||
);
|
||
return false;
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
} catch (error) {
|
||
logger.error(`The command "${error.command}" failed with the error message ${error.stderr}.`);
|
||
throw getError("EGITNOPERMISSION", context);
|
||
}
|
||
|
||
logger.success(`Allowed to push to the Git repository`);
|
||
|
||
await plugins.verifyConditions(context);
|
||
|
||
const errors = [];
|
||
context.releases = [];
|
||
const releaseToAdd = getReleaseToAdd(context);
|
||
|
||
if (releaseToAdd) {
|
||
const { lastRelease, currentRelease, nextRelease } = releaseToAdd;
|
||
|
||
nextRelease.gitHead = await getTagHead(nextRelease.gitHead, { cwd, env });
|
||
currentRelease.gitHead = await getTagHead(currentRelease.gitHead, { cwd, env });
|
||
if (context.branch.mergeRange && !semver.satisfies(nextRelease.version, context.branch.mergeRange)) {
|
||
errors.push(getError("EINVALIDMAINTENANCEMERGE", { ...context, nextRelease }));
|
||
} else {
|
||
const commits = await getCommits({ ...context, lastRelease, nextRelease });
|
||
nextRelease.notes = await plugins.generateNotes({ ...context, commits, lastRelease, nextRelease });
|
||
|
||
if (options.dryRun) {
|
||
logger.warn(`Skip ${nextRelease.gitTag} tag creation in dry-run mode`);
|
||
} else {
|
||
await addNote({ channels: [...currentRelease.channels, nextRelease.channel] }, nextRelease.gitTag, {
|
||
cwd,
|
||
env,
|
||
});
|
||
await push(options.repositoryUrl, { cwd, env });
|
||
await pushNotes(options.repositoryUrl, nextRelease.gitTag, {
|
||
cwd,
|
||
env,
|
||
});
|
||
logger.success(
|
||
`Add ${nextRelease.channel ? `channel ${nextRelease.channel}` : "default channel"} to tag ${
|
||
nextRelease.gitTag
|
||
}`
|
||
);
|
||
}
|
||
|
||
context.branch.tags.push({
|
||
version: nextRelease.version,
|
||
channel: nextRelease.channel,
|
||
gitTag: nextRelease.gitTag,
|
||
gitHead: nextRelease.gitHead,
|
||
});
|
||
|
||
const releases = await plugins.addChannel({ ...context, commits, lastRelease, currentRelease, nextRelease });
|
||
context.releases.push(...releases);
|
||
await plugins.success({ ...context, lastRelease, commits, nextRelease, releases });
|
||
}
|
||
}
|
||
|
||
if (errors.length > 0) {
|
||
throw new AggregateError(errors);
|
||
}
|
||
|
||
context.lastRelease = getLastRelease(context);
|
||
if (context.lastRelease.gitHead) {
|
||
context.lastRelease.gitHead = await getTagHead(context.lastRelease.gitHead, { cwd, env });
|
||
}
|
||
|
||
if (context.lastRelease.gitTag) {
|
||
logger.log(
|
||
`Found git tag ${context.lastRelease.gitTag} associated with version ${context.lastRelease.version} on branch ${context.branch.name}`
|
||
);
|
||
} else {
|
||
logger.log(`No git tag version found on branch ${context.branch.name}`);
|
||
}
|
||
|
||
context.commits = await getCommits(context);
|
||
|
||
const nextRelease = {
|
||
type: await plugins.analyzeCommits(context),
|
||
channel: context.branch.channel || null,
|
||
gitHead: await getGitHead({ cwd, env }),
|
||
};
|
||
if (!nextRelease.type) {
|
||
logger.log("There are no relevant changes, so no new version is released.");
|
||
return context.releases.length > 0 ? { releases: context.releases } : false;
|
||
}
|
||
|
||
context.nextRelease = nextRelease;
|
||
nextRelease.version = getNextVersion(context);
|
||
nextRelease.gitTag = makeTag(options.tagFormat, nextRelease.version);
|
||
nextRelease.name = nextRelease.gitTag;
|
||
|
||
if (context.branch.type !== "prerelease" && !semver.satisfies(nextRelease.version, context.branch.range)) {
|
||
throw getError("EINVALIDNEXTVERSION", {
|
||
...context,
|
||
validBranches: context.branches.filter(
|
||
({ type, accept }) => type !== "prerelease" && accept.includes(nextRelease.type)
|
||
),
|
||
});
|
||
}
|
||
|
||
await plugins.verifyRelease(context);
|
||
|
||
nextRelease.notes = await plugins.generateNotes(context);
|
||
|
||
await plugins.prepare(context);
|
||
|
||
if (options.dryRun) {
|
||
logger.warn(`Skip ${nextRelease.gitTag} tag creation in dry-run mode`);
|
||
} else {
|
||
// Create the tag before calling the publish plugins as some require the tag to exists
|
||
await tag(nextRelease.gitTag, nextRelease.gitHead, { cwd, env });
|
||
await addNote({ channels: [nextRelease.channel] }, nextRelease.gitTag, { cwd, env });
|
||
await push(options.repositoryUrl, { cwd, env });
|
||
await pushNotes(options.repositoryUrl, nextRelease.gitTag, { cwd, env });
|
||
logger.success(`Created tag ${nextRelease.gitTag}`);
|
||
}
|
||
|
||
const releases = await plugins.publish(context);
|
||
context.releases.push(...releases);
|
||
|
||
await plugins.success({ ...context, releases });
|
||
|
||
logger.success(
|
||
`Published release ${nextRelease.version} on ${nextRelease.channel ? nextRelease.channel : "default"} channel`
|
||
);
|
||
|
||
if (options.dryRun) {
|
||
logger.log(`Release note for version ${nextRelease.version}:`);
|
||
if (nextRelease.notes) {
|
||
context.stdout.write(await terminalOutput(nextRelease.notes));
|
||
}
|
||
}
|
||
|
||
return pick(context, ["lastRelease", "commits", "nextRelease", "releases"]);
|
||
}
|
||
|
||
async function logErrors({ logger, stderr }, err) {
|
||
const errors = extractErrors(err).sort((error) => (error.semanticRelease ? -1 : 0));
|
||
for (const error of errors) {
|
||
if (error.semanticRelease) {
|
||
logger.error(`${error.code} ${error.message}`);
|
||
if (error.details) {
|
||
stderr.write(await terminalOutput(error.details)); // eslint-disable-line no-await-in-loop
|
||
}
|
||
} else {
|
||
logger.error("An error occurred while running semantic-release: %O", error);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function callFail(context, plugins, err) {
|
||
const errors = extractErrors(err).filter((err) => err.semanticRelease);
|
||
if (errors.length > 0) {
|
||
try {
|
||
await plugins.fail({ ...context, errors });
|
||
} catch (error) {
|
||
await logErrors(context, error);
|
||
}
|
||
}
|
||
}
|
||
|
||
export default async (cliOptions = {}, { cwd = process.cwd(), env = process.env, stdout, stderr } = {}) => {
|
||
const { unhook } = hookStd(
|
||
{ silent: false, streams: [process.stdout, process.stderr, stdout, stderr].filter(Boolean) },
|
||
hideSensitive(env)
|
||
);
|
||
const context = {
|
||
cwd,
|
||
env,
|
||
stdout: stdout || process.stdout,
|
||
stderr: stderr || process.stderr,
|
||
envCi: envCi({ env, cwd }),
|
||
};
|
||
context.logger = getLogger(context);
|
||
context.logger.log(`Running ${pkg.name} version ${pkg.version}`);
|
||
try {
|
||
const { plugins, options } = await getConfig(context, cliOptions);
|
||
options.originalRepositoryURL = options.repositoryUrl;
|
||
context.options = options;
|
||
try {
|
||
const result = await run(context, plugins);
|
||
unhook();
|
||
return result;
|
||
} catch (error) {
|
||
await callFail(context, plugins, error);
|
||
throw error;
|
||
}
|
||
} catch (error) {
|
||
await logErrors(context, error);
|
||
unhook();
|
||
throw error;
|
||
}
|
||
};
|