for #2543 BREAKING CHANGE: semantic-release is now ESM-only. since it is used through its own executable, the impact on consuming projects should be minimal BREAKING CHANGE: references to plugin files in configs need to include the file extension because of executing in an ESM context
285 lines
9.9 KiB
JavaScript
285 lines
9.9 KiB
JavaScript
import {createRequire} from 'node:module';
|
||
import {pick} from 'lodash-es';
|
||
import * as marked from 'marked';
|
||
import envCi from 'env-ci';
|
||
import {hookStdout} 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} = context;
|
||
const {isCi, branch, prBranch, isPr} = context.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.gitHead, {cwd, env});
|
||
await push(options.repositoryUrl, {cwd, env});
|
||
await pushNotes(options.repositoryUrl, {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.gitHead, {cwd, env});
|
||
await push(options.repositoryUrl, {cwd, env});
|
||
await pushNotes(options.repositoryUrl, {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} = hookStdout(
|
||
{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;
|
||
}
|
||
}
|