import { ReleasePlan, Config, NewChangeset, PreState, PackageGroup, } from "@changesets/types"; import determineDependents from "./determine-dependents"; import flattenReleases from "./flatten-releases"; import matchFixedConstraint from "./match-fixed-constraint"; import applyLinks from "./apply-links"; import { incrementVersion } from "./increment"; import semverParse from "semver/functions/parse"; import { InternalError } from "@changesets/errors"; import { Packages, Package } from "@manypkg/get-packages"; import { getDependentsGraph } from "@changesets/get-dependents-graph"; import { PreInfo, InternalRelease } 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, refinedConfig.ignore, 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.ignore ); 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" && !refinedConfig.ignore.includes(pkg.packageJson.name) ) { 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[], ignored: Readonly, preState: PreState | undefined ): NewChangeset[] { for (const changeset of changesets) { // Using the following 2 arrays to decide whether a changeset // contains both ignored and not ignored packages const ignoredPackages = []; const notIgnoredPackages = []; for (const release of changeset.releases) { if ( ignored.find( (ignoredPackageName) => ignoredPackageName === release.name ) ) { ignoredPackages.push(release.name); } else { notIgnoredPackages.push(release.name); } } if (ignoredPackages.length > 0 && notIgnoredPackages.length > 0) { throw new Error( `Found mixed changeset ${changeset.id}\n` + `Found ignored packages: ${ignoredPackages.join(" ")}\n` + `Found not ignored packages: ${notIgnoredPackages.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;