import { defaultConfig } from "@changesets/config"; import * as git from "@changesets/git"; import { shouldSkipPackage } from "@changesets/should-skip-package"; import { ChangelogFunctions, Config, ModCompWithPackage, NewChangeset, ReleasePlan, } from "@changesets/types"; import { Packages } from "@manypkg/get-packages"; import detectIndent from "detect-indent"; import fs from "fs-extra"; import path from "path"; import prettier from "prettier"; import resolveFrom from "resolve-from"; import getChangelogEntry from "./get-changelog-entry"; import versionPackage from "./version-package"; function getPrettierInstance(cwd: string): typeof prettier { try { return require(require.resolve("prettier", { paths: [cwd] })); } catch (err) { if (!err || (err as any).code !== "MODULE_NOT_FOUND") { throw err; } return prettier; } } function stringDefined(s: string | undefined): s is string { return !!s; } async function getCommitsThatAddChangesets( changesetIds: string[], cwd: string ) { const paths = changesetIds.map((id) => `.changeset/${id}.md`); const commits = await git.getCommitsThatAddFiles(paths, { cwd }); if (commits.every(stringDefined)) { // We have commits for all files return commits; } // Some files didn't exist. Try legacy filenames instead const missingIds = changesetIds .map((id, i) => (commits[i] ? undefined : id)) .filter(stringDefined); const legacyPaths = missingIds.map((id) => `.changeset/${id}/changes.json`); const commitsForLegacyPaths = await git.getCommitsThatAddFiles(legacyPaths, { cwd, }); // Fill in the blanks in the array of commits changesetIds.forEach((id, i) => { if (!commits[i]) { const missingIndex = missingIds.indexOf(id); commits[i] = commitsForLegacyPaths[missingIndex]; } }); return commits; } export default async function applyReleasePlan( releasePlan: ReleasePlan, packages: Packages, config: Config = defaultConfig, snapshot?: string | boolean, contextDir = __dirname ) { let cwd = packages.root.dir; let touchedFiles = []; const packagesByName = new Map( packages.packages.map((x) => [x.packageJson.name, x]) ); let { releases, changesets } = releasePlan; let releasesWithPackage = releases.map((release) => { let pkg = packagesByName.get(release.name); if (!pkg) throw new Error( `Could not find matching package for release of: ${release.name}` ); return { ...release, ...pkg, }; }); // I think this might be the wrong place to do this, but gotta do it somewhere - add changelog entries to releases let releaseWithChangelogs = await getNewChangelogEntry( releasesWithPackage, changesets, config, cwd, contextDir ); if (releasePlan.preState !== undefined && snapshot === undefined) { if (releasePlan.preState.mode === "exit") { await fs.remove(path.join(cwd, ".changeset", "pre.json")); } else { await fs.writeFile( path.join(cwd, ".changeset", "pre.json"), JSON.stringify(releasePlan.preState, null, 2) + "\n" ); } touchedFiles.push(path.join(cwd, ".changeset", "pre.json")); } let versionsToUpdate = releases.map( ({ name, newVersion, oldVersion, type }) => ({ name, version: newVersion, oldVersion, type, dir: packagesByName.get(name)!.dir, }) ); // iterate over releases updating packages let finalisedRelease = releaseWithChangelogs.map((release) => { return versionPackage(release, versionsToUpdate, { cwd, updateInternalDependencies: config.updateInternalDependencies, onlyUpdatePeerDependentsWhenOutOfRange: config.___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH .onlyUpdatePeerDependentsWhenOutOfRange, bumpVersionsWithWorkspaceProtocolOnly: config.bumpVersionsWithWorkspaceProtocolOnly, snapshot, }); }); let prettierInstance = config.prettier !== false ? getPrettierInstance(cwd) : undefined; for (let release of finalisedRelease) { let { changelog, packageJson, dir, name } = release; const pkgJSONPath = path.resolve(dir, "package.json"); await updatePackageJson(pkgJSONPath, packageJson); touchedFiles.push(pkgJSONPath); if (changelog && changelog.length > 0) { const changelogPath = path.resolve(dir, "CHANGELOG.md"); await updateChangelog(changelogPath, changelog, name, prettierInstance); touchedFiles.push(changelogPath); } } if ( releasePlan.preState === undefined || releasePlan.preState.mode === "exit" ) { let changesetFolder = path.resolve(cwd, ".changeset"); await Promise.all( changesets.map(async (changeset) => { let changesetPath = path.resolve(changesetFolder, `${changeset.id}.md`); let changesetFolderPath = path.resolve(changesetFolder, changeset.id); if (await fs.pathExists(changesetPath)) { // DO NOT remove changeset for skipped packages // Mixed changeset that contains both skipped packages and not skipped packages are disallowed // At this point, we know there is no such changeset, because otherwise the program would've already failed, // so we just check if any skipped package exists in this changeset, and only remove it if none exists // options to skip packages were added in v2, so we don't need to do it for v1 changesets if ( !changeset.releases.find((release) => shouldSkipPackage(packagesByName.get(release.name)!, { ignore: config.ignore, allowPrivatePackages: config.privatePackages.version, }) ) ) { touchedFiles.push(changesetPath); await fs.remove(changesetPath); } // TO REMOVE LOGIC - this works to remove v1 changesets. We should be removed in the future } else if (await fs.pathExists(changesetFolderPath)) { touchedFiles.push(changesetFolderPath); await fs.remove(changesetFolderPath); } }) ); } // We return the touched files to be committed in the cli return touchedFiles; } async function getNewChangelogEntry( releasesWithPackage: ModCompWithPackage[], changesets: NewChangeset[], config: Config, cwd: string, contextDir: string ) { if (!config.changelog) { return Promise.resolve( releasesWithPackage.map((release) => ({ ...release, changelog: null, })) ); } let getChangelogFuncs: ChangelogFunctions = { getReleaseLine: () => Promise.resolve(""), getDependencyReleaseLine: () => Promise.resolve(""), }; const changelogOpts = config.changelog[1]; let changesetPath = path.join(cwd, ".changeset"); let changelogPath; try { changelogPath = resolveFrom(changesetPath, config.changelog[0]); } catch { changelogPath = resolveFrom(contextDir, config.changelog[0]); } let possibleChangelogFunc = await import(changelogPath); if (possibleChangelogFunc.default) { possibleChangelogFunc = possibleChangelogFunc.default; // Check nested default again in case it's CJS with `__esModule` interop if (possibleChangelogFunc.default) { possibleChangelogFunc = possibleChangelogFunc.default; } } if ( typeof possibleChangelogFunc.getReleaseLine === "function" && typeof possibleChangelogFunc.getDependencyReleaseLine === "function" ) { getChangelogFuncs = possibleChangelogFunc; } else { throw new Error("Could not resolve changelog generation functions"); } let commits = await getCommitsThatAddChangesets( changesets.map((cs) => cs.id), cwd ); let moddedChangesets = changesets.map((cs, i) => ({ ...cs, commit: commits[i], })); return Promise.all( releasesWithPackage.map(async (release) => { let changelog = await getChangelogEntry( cwd, release, releasesWithPackage, moddedChangesets, getChangelogFuncs, changelogOpts, { updateInternalDependencies: config.updateInternalDependencies, onlyUpdatePeerDependentsWhenOutOfRange: config.___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH .onlyUpdatePeerDependentsWhenOutOfRange, } ); return { ...release, changelog, }; }) ).catch((e) => { console.error( "The following error was encountered while generating changelog entries" ); console.error( "We have escaped applying the changesets, and no files should have been affected" ); throw e; }); } async function updateChangelog( changelogPath: string, changelog: string, name: string, prettierInstance: typeof prettier | undefined ) { let templateString = `\n\n${changelog.trim()}\n`; try { if (fs.existsSync(changelogPath)) { await prependFile(changelogPath, templateString, name, prettierInstance); } else { await writeFormattedMarkdownFile( changelogPath, `# ${name}${templateString}`, prettierInstance ); } } catch (e) { console.warn(e); } } async function updatePackageJson( pkgJsonPath: string, pkgJson: any ): Promise { const pkgRaw = await fs.readFile(pkgJsonPath, "utf-8"); const indent = detectIndent(pkgRaw).indent || " "; const stringified = JSON.stringify(pkgJson, null, indent) + (pkgRaw.endsWith("\n") ? "\n" : ""); return fs.writeFile(pkgJsonPath, stringified); } async function prependFile( filePath: string, data: string, name: string, prettierInstance: typeof prettier | undefined ) { const fileData = fs.readFileSync(filePath).toString(); // if the file exists but doesn't have the header, we'll add it in if (!fileData) { const completelyNewChangelog = `# ${name}${data}`; await writeFormattedMarkdownFile( filePath, completelyNewChangelog, prettierInstance ); return; } let newChangelog: string; // Require just 2 version numbers here, assuming `## 1.1` is a valid version heading. // Our version headings start with ##, we are more permissive here though. // Note: we also need to handle prerelease versions here but that's already covered by the regex. const isVersionHeading = /^#{1,6}\s+\d+\.\d+/.test(fileData); if (isVersionHeading) { // file starts with a version heading (no package title) - prepend before everything newChangelog = data.trimStart() + fileData; } else { const index = fileData.indexOf("\n"); newChangelog = index === -1 ? fileData + data // treat the whole file as header : fileData.slice(0, index) + data + fileData.slice(index + 1); } await writeFormattedMarkdownFile(filePath, newChangelog, prettierInstance); } async function writeFormattedMarkdownFile( filePath: string, content: string, prettierInstance: typeof prettier | undefined ) { await fs.writeFile( filePath, prettierInstance ? // Prettier v3 returns a promise await prettierInstance.format(content, { ...(await prettierInstance.resolveConfig(filePath)), filepath: filePath, parser: "markdown", }) : content ); }