semantic-release
Version:
Automated semver compliant package publishing
291 lines (251 loc) • 10.3 kB
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;
}
};