diff --git a/src/release-specification.test.ts b/src/release-specification.test.ts index d3504de..c665acb 100644 --- a/src/release-specification.test.ts +++ b/src/release-specification.test.ts @@ -684,6 +684,630 @@ ${releaseSpecificationPath} }); }); + it('throws if there are any packages in the release with a major version bump using the word "major", but any of their dependents defined as "dependencies" are not listed in the release', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + validatedManifest: { + dependencies: { + a: '1.0.0', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'major', + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +* The following dependents of package 'a', which is being released with a major version bump, are missing from the release spec. + + - b + + Consider including them in the release spec so that they are compatible with the new 'a' version. + + If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example: + + packages: + b: intentionally-skip + +The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool. + +${releaseSpecificationPath} +`.trim(), + ); + }); + }); + + it('throws if there are any packages in the release with a major version bump using a literal version, but any of their dependents defined as "dependencies" are not listed in the release', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', '2.1.4', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + validatedManifest: { + dependencies: { + a: '2.1.4', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: '3.0.0', + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +* The following dependents of package 'a', which is being released with a major version bump, are missing from the release spec. + + - b + + Consider including them in the release spec so that they are compatible with the new 'a' version. + + If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example: + + packages: + b: intentionally-skip + +The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool. + +${releaseSpecificationPath} +`.trim(), + ); + }); + }); + + it('throws if there are any packages in the release with a major version bump using the word "major", but any of their dependents defined as "peerDependencies" are not listed in the release', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + validatedManifest: { + peerDependencies: { + a: '1.0.0', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'major', + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +* The following dependents of package 'a', which is being released with a major version bump, are missing from the release spec. + + - b + + Consider including them in the release spec so that they are compatible with the new 'a' version. + + If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example: + + packages: + b: intentionally-skip + +The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool. + +${releaseSpecificationPath} +`.trim(), + ); + }); + }); + + it('throws if there are any packages in the release with a major version bump using a literal version, but any of their dependents defined as "peerDependencies" are not listed in the release', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', '2.1.4', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + validatedManifest: { + peerDependencies: { + a: '2.1.4', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: '3.0.0', + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +* The following dependents of package 'a', which is being released with a major version bump, are missing from the release spec. + + - b + + Consider including them in the release spec so that they are compatible with the new 'a' version. + + If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example: + + packages: + b: intentionally-skip + +The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool. + +${releaseSpecificationPath} +`.trim(), + ); + }); + }); + + it('throws if there are any packages in the release with a major version bump using the word "major", but their dependents via "dependencies" have their version specified as null in the release spec', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + validatedManifest: { + dependencies: { + a: '1.0.0', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'major', + b: null, + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +* The following dependents of package 'a', which is being released with a major version bump, are missing from the release spec. + + - b + + Consider including them in the release spec so that they are compatible with the new 'a' version. + + If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example: + + packages: + b: intentionally-skip + +The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool. + +${releaseSpecificationPath} +`.trim(), + ); + }); + }); + + it('throws if there are any packages in the release with a major version bump using a literal version, but their dependents via "dependencies" have their version specified as null in the release spec', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', '2.1.4', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + validatedManifest: { + dependencies: { + a: '2.1.4', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: '3.0.0', + b: null, + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +* The following dependents of package 'a', which is being released with a major version bump, are missing from the release spec. + + - b + + Consider including them in the release spec so that they are compatible with the new 'a' version. + + If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example: + + packages: + b: intentionally-skip + +The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool. + +${releaseSpecificationPath} +`.trim(), + ); + }); + }); + + it('throws if there are any packages in the release with a major version bump using the word "major", but their dependents via "peerDependencies" have their version specified as null in the release spec', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + validatedManifest: { + peerDependencies: { + a: '1.0.0', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'major', + b: null, + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +* The following dependents of package 'a', which is being released with a major version bump, are missing from the release spec. + + - b + + Consider including them in the release spec so that they are compatible with the new 'a' version. + + If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example: + + packages: + b: intentionally-skip + +The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool. + +${releaseSpecificationPath} +`.trim(), + ); + }); + }); + + it('throws if there are any packages in the release with a major version bump using a literal version, but their dependents via "peerDependencies" have their version specified as null in the release spec', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', '2.1.4', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + validatedManifest: { + peerDependencies: { + a: '2.1.4', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: '3.0.0', + b: null, + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +* The following dependents of package 'a', which is being released with a major version bump, are missing from the release spec. + + - b + + Consider including them in the release spec so that they are compatible with the new 'a' version. + + If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example: + + packages: + b: intentionally-skip + +The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool. + +${releaseSpecificationPath} +`.trim(), + ); + }); + }); + + it('does not throw an error if packages in the release with a major version bump using the word "major", have their dependents via "dependencies" with their version specified as "intentionally-skip" in the release spec', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + validatedManifest: { + dependencies: { + a: '1.0.0', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'major', + b: 'intentionally-skip', + }, + }), + ); + + const releaseSpecification = await validateReleaseSpecification( + project, + releaseSpecificationPath, + ); + + expect(releaseSpecification).toStrictEqual({ + packages: { + a: 'major', + }, + path: releaseSpecificationPath, + }); + }); + }); + + it('does not throw an error if packages in the release with a major version bump using a literal version, have their dependents via "dependencies" with their version specified as "intentionally-skip" in the release spec', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', '2.1.4', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + validatedManifest: { + dependencies: { + a: '2.1.4', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: '3.0.0', + b: 'intentionally-skip', + }, + }), + ); + + const releaseSpecification = await validateReleaseSpecification( + project, + releaseSpecificationPath, + ); + + expect(releaseSpecification).toStrictEqual({ + packages: { + a: new SemVer('3.0.0'), + }, + path: releaseSpecificationPath, + }); + }); + }); + + it('does not throw an error if packages in the release with a major version bump using the word "major", have their dependents via "peerDependencies" with their version specified as "intentionally-skip" in the release spec', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + validatedManifest: { + peerDependencies: { + a: '1.0.0', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'major', + b: 'intentionally-skip', + }, + }), + ); + + const releaseSpecification = await validateReleaseSpecification( + project, + releaseSpecificationPath, + ); + + expect(releaseSpecification).toStrictEqual({ + packages: { + a: 'major', + }, + path: releaseSpecificationPath, + }); + }); + }); + + it('does not throw an error if packages in the release with a major version bump using a literal version, have their dependents via "peerDependencies" with their version specified as "intentionally-skip" in the release spec', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', '2.1.4', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + validatedManifest: { + peerDependencies: { + a: '2.1.4', + }, + }, + }), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: '3.0.0', + b: 'intentionally-skip', + }, + }), + ); + + const releaseSpecification = await validateReleaseSpecification( + project, + releaseSpecificationPath, + ); + + expect(releaseSpecification).toStrictEqual({ + packages: { + a: new SemVer('3.0.0'), + }, + path: releaseSpecificationPath, + }); + }); + }); + it("throws if there are any packages not listed in the release which have changed and are being defined as 'dependencies' by other packages which are listed", async () => { await withSandbox(async (sandbox) => { const project = buildMockProject({ diff --git a/src/release-specification.ts b/src/release-specification.ts index cbc63ed..9ab314e 100644 --- a/src/release-specification.ts +++ b/src/release-specification.ts @@ -1,5 +1,6 @@ import fs, { WriteStream } from 'fs'; import YAML from 'yaml'; +import { diff } from 'semver'; import { Editor } from './editor'; import { readFile } from './fs'; import { @@ -210,23 +211,49 @@ export async function validateReleaseSpecification( (packageName) => project.workspacePackages[packageName].hasChangesSinceLatestRelease, ); - const packageNamesToReportMissing = changedPackageNames.filter( - (packageName) => { + + const missingChangedPackageNames = changedPackageNames.filter( + (packageName) => + !hasProperty(unvalidatedReleaseSpecification.packages, packageName) || + unvalidatedReleaseSpecification.packages[packageName] === null, + ); + + const packageNamesToReportMissing = missingChangedPackageNames.filter( + (missingChangedPackageName) => { + const missingChangedPackage = + project.workspacePackages[missingChangedPackageName]; + const isInternalDependency = Object.values( project.workspacePackages, - ).some((pkg) => { - const { dependencies, peerDependencies } = pkg.validatedManifest; + ).some((workspacePackage) => { + const { dependencies, peerDependencies } = + workspacePackage.validatedManifest; return ( - hasProperty(dependencies, packageName) || - hasProperty(peerDependencies, packageName) + hasProperty(dependencies, missingChangedPackageName) || + hasProperty(peerDependencies, missingChangedPackageName) ); }); - return ( - (!hasProperty(unvalidatedReleaseSpecification.packages, packageName) || - unvalidatedReleaseSpecification.packages[packageName] === null) && - !isInternalDependency - ); + const hasInternalDependencyWithBreakingChanges = Object.keys({ + ...missingChangedPackage.validatedManifest.dependencies, + ...missingChangedPackage.validatedManifest.peerDependencies, + }) + .filter((dependency) => project.workspacePackages[dependency]) + .some((dependency) => { + const internalDependencyVersionSpecifierOrDirective = + unvalidatedReleaseSpecification.packages[dependency]; + return ( + internalDependencyVersionSpecifierOrDirective && + (internalDependencyVersionSpecifierOrDirective === 'major' || + (isValidSemver(internalDependencyVersionSpecifierOrDirective) && + diff( + missingChangedPackage.validatedManifest.version, + internalDependencyVersionSpecifierOrDirective, + ) === 'major')) + ); + }); + + return !isInternalDependency && !hasInternalDependencyWithBreakingChanges; }, ); @@ -259,14 +286,14 @@ export async function validateReleaseSpecification( } Object.entries(unvalidatedReleaseSpecification.packages).forEach( - ([packageName, versionSpecifierOrDirective], index) => { + ([changedPackageName, versionSpecifierOrDirective], index) => { const lineNumber = indexOfFirstUsableLine + index + 2; - const pkg = project.workspacePackages[packageName]; + const changedPackage = project.workspacePackages[changedPackageName]; - if (pkg === undefined) { + if (changedPackage === undefined) { errors.push({ message: `${JSON.stringify( - packageName, + changedPackageName, )} is not a package in the project`, lineNumber, }); @@ -282,7 +309,7 @@ export async function validateReleaseSpecification( message: [ `${JSON.stringify( versionSpecifierOrDirective, - )} is not a valid version specifier for package "${packageName}"`, + )} is not a valid version specifier for package "${changedPackageName}"`, `(must be "major", "minor", or "patch"; or a version string with major, minor, and patch parts, such as "1.2.3")`, ], lineNumber, @@ -291,7 +318,7 @@ export async function validateReleaseSpecification( if (isValidSemver(versionSpecifierOrDirective)) { const comparison = new SemVer(versionSpecifierOrDirective).compare( - project.workspacePackages[packageName].validatedManifest.version, + changedPackage.validatedManifest.version, ); if (comparison === 0) { @@ -299,8 +326,8 @@ export async function validateReleaseSpecification( message: [ `${JSON.stringify( versionSpecifierOrDirective, - )} is not a valid version specifier for package "${packageName}"`, - `("${packageName}" is already at version "${versionSpecifierOrDirective}")`, + )} is not a valid version specifier for package "${changedPackageName}"`, + `("${changedPackageName}" is already at version "${versionSpecifierOrDirective}")`, ], lineNumber, }); @@ -309,24 +336,83 @@ export async function validateReleaseSpecification( message: [ `${JSON.stringify( versionSpecifierOrDirective, - )} is not a valid version specifier for package "${packageName}"`, - `("${packageName}" is at a greater version "${project.workspacePackages[packageName].validatedManifest.version}")`, + )} is not a valid version specifier for package "${changedPackageName}"`, + `("${changedPackageName}" is at a greater version "${project.workspacePackages[changedPackageName].validatedManifest.version}")`, ], lineNumber, }); } } + // Check to compel users to release packages with breaking changes alongside their dependents + if ( + versionSpecifierOrDirective === 'major' || + (isValidSemver(versionSpecifierOrDirective) && + diff( + changedPackage.validatedManifest.version, + versionSpecifierOrDirective, + ) === 'major') + ) { + const dependentNames = Object.keys(project.workspacePackages).filter( + (possibleDependentName) => { + const possibleDependent = + project.workspacePackages[possibleDependentName]; + const { dependencies, peerDependencies } = + possibleDependent.validatedManifest; + return ( + hasProperty(dependencies, changedPackageName) || + hasProperty(peerDependencies, changedPackageName) + ); + }, + ); + const changedDependentNames = dependentNames.filter( + (possibleDependentName) => { + return project.workspacePackages[possibleDependentName] + .hasChangesSinceLatestRelease; + }, + ); + const missingDependentNames = changedDependentNames.filter( + (dependentName) => { + return !unvalidatedReleaseSpecification.packages[dependentName]; + }, + ); + + if (missingDependentNames.length > 0) { + errors.push({ + message: [ + `The following dependents of package '${changedPackageName}', which is being released with a major version bump, are missing from the release spec.`, + missingDependentNames + .map((dependent) => ` - ${dependent}`) + .join('\n'), + ` Consider including them in the release spec so that they are compatible with the new '${changedPackageName}' version.`, + ` If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example:`, + YAML.stringify({ + packages: missingDependentNames.reduce((object, dependent) => { + return { + ...object, + [dependent]: INTENTIONALLY_SKIP_PACKAGE_DIRECTIVE, + }; + }, {}), + }) + .trim() + .split('\n') + .map((line) => ` ${line}`) + .join('\n'), + ].join('\n\n'), + }); + } + } + // Check to compel users to release new versions of dependencies alongside their dependents if ( - pkg && + changedPackage && versionSpecifierOrDirective && (hasProperty(IncrementableVersionParts, versionSpecifierOrDirective) || isValidSemver(versionSpecifierOrDirective)) ) { const missingDependencies = Object.keys({ - ...pkg.validatedManifest.dependencies, - ...pkg.validatedManifest.peerDependencies, + ...changedPackage.validatedManifest.dependencies, + ...changedPackage.validatedManifest.peerDependencies, }).filter((dependency) => { return ( project.workspacePackages[dependency] @@ -338,11 +424,11 @@ export async function validateReleaseSpecification( if (missingDependencies.length > 0) { errors.push({ message: [ - `The following packages, which are dependencies or peer dependencies of the package '${packageName}' being released, are missing from the release spec.`, + `The following packages, which are dependencies or peer dependencies of the package '${changedPackageName}' being released, are missing from the release spec.`, missingDependencies .map((dependency) => ` - ${dependency}`) .join('\n'), - ` These packages may have changes that '${packageName}' relies upon. Consider including them in the release spec.`, + ` These packages may have changes that '${changedPackageName}' relies upon. Consider including them in the release spec.`, ` If you are ABSOLUTELY SURE these packages are safe to omit, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example:`, YAML.stringify({ packages: missingDependencies.reduce((object, dependency) => {