diff --git a/src/release-specification.test.ts b/src/release-specification.test.ts index 7e8c107..3dfe295 100644 --- a/src/release-specification.test.ts +++ b/src/release-specification.test.ts @@ -242,7 +242,13 @@ packages: await withSandbox(async (sandbox) => { const project = buildMockProject({ workspacePackages: { - a: buildMockPackage('a'), + a: buildMockPackage('a', { + unvalidatedManifest: { + dependencies: { + b: '1.0.0', + }, + }, + }), b: buildMockPackage('b'), c: buildMockPackage('c'), d: buildMockPackage('d'), @@ -678,6 +684,116 @@ Your release spec could not be processed due to the following issues: 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 listed in the release but their dependent via "dependencies" is not listed', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: false, + unvalidatedManifest: { + 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 packages, which depends on released package a, are missing. + + - b + + Consider including them in the release spec so that they won't break in production. + + If you are ABSOLUTELY SURE that this won't occur, 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 listed in the release but their dependent via "peerDependencies" is not listed', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: false, + unvalidatedManifest: { + 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 packages, which depends on released package a, are missing. + + - b + + Consider including them in the release spec so that they won't break in production. + + If you are ABSOLUTELY SURE that this won't occur, 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(), ); diff --git a/src/release-specification.ts b/src/release-specification.ts index 3dc89fb..42adcca 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 { @@ -304,6 +305,79 @@ export async function validateReleaseSpecification( }, ); + Object.keys(unvalidatedReleaseSpecification.packages).forEach( + (packageName) => { + const versionSpecifierOrDirective = + unvalidatedReleaseSpecification.packages[packageName]; + const pkg = project.workspacePackages[packageName]; + + if ( + versionSpecifierOrDirective === 'major' || + (isValidSemver(versionSpecifierOrDirective) && + diff( + pkg.validatedManifest.version, + versionSpecifierOrDirective as string, + ) === 'major') + ) { + const missingDependents = Object.values( + project.workspacePackages, + ).filter((dependent) => { + const { dependencies, peerDependencies } = + dependent.unvalidatedManifest; + const isDependent = + (dependencies && hasProperty(dependencies, packageName)) || + (peerDependencies && hasProperty(peerDependencies, packageName)); + + if (!isDependent) { + return false; + } + + const dependentVersionSpecifierOrDirective = + unvalidatedReleaseSpecification.packages[ + dependent.validatedManifest.name + ]; + + return ( + dependentVersionSpecifierOrDirective !== SKIP_PACKAGE_DIRECTIVE && + dependentVersionSpecifierOrDirective !== + INTENTIONALLY_SKIP_PACKAGE_DIRECTIVE && + !hasProperty( + IncrementableVersionParts, + dependentVersionSpecifierOrDirective, + ) && + !isValidSemver(dependentVersionSpecifierOrDirective) + ); + }); + + if (missingDependents.length > 0) { + errors.push({ + message: [ + `The following packages, which depends on released package ${packageName}, are missing.`, + missingDependents + .map((dependent) => ` - ${dependent.validatedManifest.name}`) + .join('\n'), + " Consider including them in the release spec so that they won't break in production.", + ` If you are ABSOLUTELY SURE that this won't occur, however, and want to postpone the release of a package, then list it with a directive of "intentionally-skip". For example:`, + YAML.stringify({ + packages: missingDependents.reduce((object, dependent) => { + return { + ...object, + [dependent.validatedManifest.name]: + INTENTIONALLY_SKIP_PACKAGE_DIRECTIVE, + }; + }, {}), + }) + .trim() + .split('\n') + .map((line) => ` ${line}`) + .join('\n'), + ].join('\n\n'), + }); + } + } + }, + ); + if (errors.length > 0) { const message = [ 'Your release spec could not be processed due to the following issues:', diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index ac2e0fc..79eb31f 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -4,7 +4,10 @@ import { SemVer } from 'semver'; import { isPlainObject } from '@metamask/utils'; import type { Package } from '../../src/package'; import { PackageManifestFieldNames } from '../../src/package-manifest'; -import type { ValidatedPackageManifest } from '../../src/package-manifest'; +import type { + UnvalidatedPackageManifest, + ValidatedPackageManifest, +} from '../../src/package-manifest'; import type { Project } from '../../src/project'; /** @@ -35,6 +38,7 @@ type MockPackageOverrides = Omit< Partial, PackageManifestFieldNames.Name | PackageManifestFieldNames.Version >; + unvalidatedManifest?: UnvalidatedPackageManifest; }; /** @@ -102,6 +106,7 @@ export function buildMockPackage( const { validatedManifest = {}, + unvalidatedManifest = {}, directoryPath = `/path/to/packages/${name}`, manifestPath = path.join(directoryPath, 'package.json'), changelogPath = path.join(directoryPath, 'CHANGELOG.md'), @@ -110,7 +115,7 @@ export function buildMockPackage( return { directoryPath, - unvalidatedManifest: {}, + unvalidatedManifest, validatedManifest: buildMockManifest({ ...validatedManifest, [PackageManifestFieldNames.Name]: name,