import { InternalError } from '@changesets/errors'; import { getDependentsGraph } from '@changesets/get-dependents-graph'; import { shouldSkipPackage } from '@changesets/should-skip-package'; import { Config, NewChangeset, PackageGroup, PreState, ReleasePlan, } from '@changesets/types'; import { Package, Packages } from '@manypkg/get-packages'; import semverParse from 'semver/functions/parse'; import applyLinks from './apply-links'; import determineDependents from './determine-dependents'; import flattenReleases from './flatten-releases'; import { incrementVersion } from './increment'; import matchFixedConstraint from './match-fixed-constraint'; import { InternalRelease, PreInfo } from './types'; type SnapshotReleaseParameters = { tag?: string | undefined; commit?: string | undefined; }; function getPreVersion(version: string) { let parsed = semverParse(version)!; let preVersion = parsed.prerelease[1] === undefined ? -1 : parsed.prerelease[1]; if (typeof preVersion !== 'number') { throw new InternalError('preVersion is not a number'); } preVersion++; return preVersion; } function getSnapshotSuffix( template: Config['snapshot']['prereleaseTemplate'], snapshotParameters: SnapshotReleaseParameters, ): string { let snapshotRefDate = new Date(); const placeholderValues = { commit: snapshotParameters.commit, tag: snapshotParameters.tag, timestamp: snapshotRefDate.getTime().toString(), datetime: snapshotRefDate .toISOString() .replace(/\.\d{3}Z$/, '') .replace(/[^\d]/g, ''), }; // We need a special handling because we need to handle a case where `--snapshot` is used without any template, // and the resulting version needs to be composed without a tag. if (!template) { return [placeholderValues.tag, placeholderValues.datetime] .filter(Boolean) .join('-'); } const placeholders = Object.keys(placeholderValues) as Array< keyof typeof placeholderValues >; if (!template.includes(`{tag}`) && placeholderValues.tag !== undefined) { throw new Error( `Failed to compose snapshot version: "{tag}" placeholder is missing, but the snapshot parameter is defined (value: '${placeholderValues.tag}')`, ); } return placeholders.reduce((prev, key) => { return prev.replace(new RegExp(`\\{${key}\\}`, 'g'), () => { const value = placeholderValues[key]; if (value === undefined) { throw new Error( `Failed to compose snapshot version: "{${key}}" placeholder is used without having a value defined!`, ); } return value; }); }, template); } function getSnapshotVersion( release: InternalRelease, preInfo: PreInfo | undefined, useCalculatedVersion: boolean, snapshotSuffix: string, ): string { if (release.type === 'none') { return release.oldVersion; } /** * Using version as 0.0.0 so that it does not hinder with other version release * For example; * if user has a regular pre-release at 1.0.0-beta.0 and then you had a snapshot pre-release at 1.0.0-canary-git-hash * and a consumer is using the range ^1.0.0-beta, most people would expect that range to resolve to 1.0.0-beta.0 * but it'll actually resolve to 1.0.0-canary-hash. Using 0.0.0 solves this problem because it won't conflict with other versions. * * You can set `snapshot.useCalculatedVersion` flag to true to use calculated versions if you don't care about the above problem. */ const baseVersion = useCalculatedVersion ? incrementVersion(release, preInfo) : `0.0.0`; return `${baseVersion}-${snapshotSuffix}`; } function getNewVersion( release: InternalRelease, preInfo: PreInfo | undefined, ): string { if (release.type === 'none') { return release.oldVersion; } return incrementVersion(release, preInfo); } type OptionalProp = Omit & Partial>; function assembleReleasePlan( changesets: NewChangeset[], packages: Packages, config: OptionalProp, // intentionally not using an optional parameter here so the result of `readPreState` has to be passed in here preState: PreState | undefined, // snapshot: undefined -> not using snaphot // snapshot: { tag: undefined } -> --snapshot (empty tag) // snapshot: { tag: "canary" } -> --snapshot canary snapshot?: SnapshotReleaseParameters | string | boolean, ): ReleasePlan { // TODO: remove `refined*` in the next major version of this package // just use `config` and `snapshot` parameters directly, typed as: `config: Config, snapshot?: SnapshotReleaseParameters` const refinedConfig: Config = config.snapshot ? (config as Config) : { ...config, snapshot: { prereleaseTemplate: null, useCalculatedVersion: ( config.___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH as any ).useCalculatedVersionForSnapshots, }, }; const refinedSnapshot: SnapshotReleaseParameters | undefined = typeof snapshot === 'string' ? { tag: snapshot } : typeof snapshot === 'boolean' ? { tag: undefined } : snapshot; let packagesByName = new Map( packages.packages.map((x) => [x.packageJson.name, x]), ); const relevantChangesets = getRelevantChangesets( changesets, packagesByName, refinedConfig, preState, ); const preInfo = getPreInfo( changesets, packagesByName, refinedConfig, preState, ); // releases is, at this point a list of all packages we are going to releases, // flattened down to one release per package, having a reference back to their // changesets, and with a calculated new versions let releases = flattenReleases( relevantChangesets, packagesByName, refinedConfig, ); let dependencyGraph = getDependentsGraph(packages, { bumpVersionsWithWorkspaceProtocolOnly: refinedConfig.bumpVersionsWithWorkspaceProtocolOnly, }); let releasesValidated = false; while (releasesValidated === false) { // The map passed in to determineDependents will be mutated let dependentAdded = determineDependents({ releases, packagesByName, dependencyGraph, preInfo, config: refinedConfig, }); // `releases` might get mutated here let fixedConstraintUpdated = matchFixedConstraint( releases, packagesByName, refinedConfig, ); let linksUpdated = applyLinks( releases, packagesByName, refinedConfig.linked, ); releasesValidated = !linksUpdated && !dependentAdded && !fixedConstraintUpdated; } if (preInfo?.state.mode === 'exit') { for (let pkg of packages.packages) { // If a package had a prerelease, but didn't trigger a version bump in the regular release, // we want to give it a patch release. // Detailed explanation at https://github.com/changesets/changesets/pull/382#discussion_r434434182 if (preInfo.preVersions.get(pkg.packageJson.name) !== 0) { const existingRelease = releases.get(pkg.packageJson.name); if (!existingRelease) { releases.set(pkg.packageJson.name, { name: pkg.packageJson.name, type: 'patch', oldVersion: pkg.packageJson.version, changesets: [], }); } else if ( existingRelease.type === 'none' && !shouldSkipPackage(pkg, { ignore: refinedConfig.ignore, allowPrivatePackages: refinedConfig.privatePackages.version, }) ) { existingRelease.type = 'patch'; } } } } // Caching the snapshot version here and use this if it is snapshot release const snapshotSuffix = refinedSnapshot && getSnapshotSuffix( refinedConfig.snapshot.prereleaseTemplate, refinedSnapshot, ); return { changesets: relevantChangesets, releases: [...releases.values()].map((incompleteRelease) => { return { ...incompleteRelease, newVersion: snapshotSuffix ? getSnapshotVersion( incompleteRelease, preInfo, refinedConfig.snapshot.useCalculatedVersion, snapshotSuffix, ) : getNewVersion(incompleteRelease, preInfo), }; }), preState: preInfo?.state, }; } function getRelevantChangesets( changesets: NewChangeset[], packagesByName: Map, config: Config, preState: PreState | undefined, ): NewChangeset[] { for (const changeset of changesets) { // Using the following 2 arrays to decide whether a changeset // contains both skipped and not skipped packages const skippedPackages = []; const notSkippedPackages = []; for (const release of changeset.releases) { if ( shouldSkipPackage(packagesByName.get(release.name)!, { ignore: config.ignore, allowPrivatePackages: config.privatePackages.version, }) ) { skippedPackages.push(release.name); } else { notSkippedPackages.push(release.name); } } if (skippedPackages.length > 0 && notSkippedPackages.length > 0) { throw new Error( `Found mixed changeset ${changeset.id}\n` + `Found ignored packages: ${skippedPackages.join(' ')}\n` + `Found not ignored packages: ${notSkippedPackages.join(' ')}\n` + 'Mixed changesets that contain both ignored and not ignored packages are not allowed', ); } } if (preState && preState.mode !== 'exit') { let usedChangesetIds = new Set(preState.changesets); return changesets.filter( (changeset) => !usedChangesetIds.has(changeset.id), ); } return changesets; } function getHighestPreVersion( packageGroup: PackageGroup, packagesByName: Map, ): number { let highestPreVersion = 0; for (let pkg of packageGroup) { highestPreVersion = Math.max( getPreVersion(packagesByName.get(pkg)!.packageJson.version), highestPreVersion, ); } return highestPreVersion; } function getPreInfo( changesets: NewChangeset[], packagesByName: Map, config: Config, preState: PreState | undefined, ): PreInfo | undefined { if (preState === undefined) { return; } let updatedPreState = { ...preState, changesets: changesets.map((changeset) => changeset.id), initialVersions: { ...preState.initialVersions, }, }; for (const [, pkg] of packagesByName) { if (updatedPreState.initialVersions[pkg.packageJson.name] === undefined) { updatedPreState.initialVersions[pkg.packageJson.name] = pkg.packageJson.version; } } // Populate preVersion // preVersion is the map between package name and its next pre version number. let preVersions = new Map(); for (const [, pkg] of packagesByName) { preVersions.set( pkg.packageJson.name, getPreVersion(pkg.packageJson.version), ); } for (let fixedGroup of config.fixed) { let highestPreVersion = getHighestPreVersion(fixedGroup, packagesByName); for (let fixedPackage of fixedGroup) { preVersions.set(fixedPackage, highestPreVersion); } } for (let linkedGroup of config.linked) { let highestPreVersion = getHighestPreVersion(linkedGroup, packagesByName); for (let linkedPackage of linkedGroup) { preVersions.set(linkedPackage, highestPreVersion); } } return { state: updatedPreState, preVersions, }; } export default assembleReleasePlan;