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; } };