diff --git a/README.md b/README.md index b5822962..8c5db7e9 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,11 @@ the root `package.json` file, for example: } ``` +You can set the `mainWorkspace` [plugin option](#plugin-options) to use in +notifications of new releases (e.g. in issue and pull request comments made by +the [@semantic-release/github](https://github.com/semantic-release/github) +plugin. + See [our roadmap](#roadmap) for further implementation status. ## Configuration @@ -170,11 +175,12 @@ for example: } ``` -| Options | Description | Default | -| ------------ | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `npmPublish` | Whether to publish the NPM package to the registry. If `false` the `package.json` version will still be updated. | `false` if the `package.json` [private](https://docs.npmjs.com/files/package.json#private) property is `true`, `true` otherwise. | -| `pkgRoot` | Directory path to publish. | `.` | -| `tarballDir` | Directory path in which to write the package tarball. If `false` the tarball is not kept on the file system. | `false` | +| Options | Description | Default | +| --------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `npmPublish` | Whether to publish the NPM package to the registry. If `false` the `package.json` version will still be updated. | `false` if the `package.json` [private](https://docs.npmjs.com/files/package.json#private) property is `true`, `true` otherwise. | +| `pkgRoot` | Directory path to publish. | `.` | +| `tarballDir` | Directory path in which to write the package tarball. If `false` the tarball is not kept on the file system. | `false` | +| `mainWorkspace` | Name of monorepo workspace to be used in release info | | > **Note**: the `pkgRoot` directory must contain a `package.json`. The version > will be updated only in the `package.json` within the `pkgRoot` directory. diff --git a/src/add-channel.ts b/src/add-channel.ts index 4ddcb3e5..5cae8c4e 100644 --- a/src/add-channel.ts +++ b/src/add-channel.ts @@ -8,6 +8,7 @@ import { getRegistry } from "./get-registry.js"; import { getReleaseInfo } from "./get-release-info.js"; import { getYarnConfig } from "./get-yarn-config.js"; import { reasonToNotPublish, shouldPublish } from "./should-publish.js"; +import { getWorkspaces } from "./yarn-workspaces.js"; export async function addChannel( pluginConfig: PluginConfig, @@ -32,31 +33,32 @@ export async function addChannel( const distTag = getChannel(channel!); const isMonorepo = typeof pkg.workspaces !== "undefined"; - if (isMonorepo) { - logger.log(`Adding npm tags to monorepo workspaces is not supported yet`); - return false; - } + const packagesToTag = isMonorepo + ? (await getWorkspaces({ cwd })).map(({ name }) => name) + : [pkg.name]; - logger.log( - `Adding version ${version} to npm registry ${registry} on dist-tag ${distTag}` - ); - const result = execa( - "yarn", - ["npm", "tag", "add", `${pkg.name}@${version}`, distTag], - { - cwd: basePath, - env, - } - ); - result.stdout!.pipe(stdout, { end: false }); - result.stderr!.pipe(stderr, { end: false }); - await result; + for (const name of packagesToTag) { + logger.log( + `Adding version ${version} to npm registry ${registry} (tagged as @${distTag})` + ); + const result = execa( + "yarn", + ["npm", "tag", "add", `${name}@${version}`, distTag], + { + cwd: basePath, + env, + } + ); + result.stdout!.pipe(stdout, { end: false }); + result.stderr!.pipe(stderr, { end: false }); + await result; - logger.log( - `Added ${pkg.name}@${version} to dist-tag @${distTag} on ${registry}` - ); + logger.log( + `Added ${name}@${version} on ${registry} (tagged as @${distTag})` + ); + } - return getReleaseInfo(pkg, context, distTag, registry); + return getReleaseInfo(pkg, pluginConfig, context, distTag, registry); } const reason = reasonToNotPublish(pluginConfig, pkg); diff --git a/src/definitions/errors.ts b/src/definitions/errors.ts index 7c4caa53..7c2e962c 100644 --- a/src/definitions/errors.ts +++ b/src/definitions/errors.ts @@ -37,6 +37,21 @@ Your configuration for the "pkgRoot" option is "${pkgRoot}".`, }; } +export function EINVALIDMAINWORKSPACE({ + mainWorkspace, +}: { + mainWorkspace: unknown; +}) { + return { + message: 'Invalid "mainWorkspace" option.', + details: `The [mainWorkspace option](${linkify( + "README.md#plugin-options" + )}) option, if defined, must be a "String". + +Your configuration for the "mainWorkspace" option is "${mainWorkspace}".`, + }; +} + export function ENONPMTOKEN({ registry }: { registry: string }) { return { message: "No NPM access token specified.", diff --git a/src/definitions/pluginConfig.ts b/src/definitions/pluginConfig.ts index 87541d80..62adf00a 100644 --- a/src/definitions/pluginConfig.ts +++ b/src/definitions/pluginConfig.ts @@ -2,4 +2,5 @@ export type PluginConfig = { npmPublish?: boolean; tarballDir?: string; pkgRoot?: string; + mainWorkspace?: string; }; diff --git a/src/get-release-info.ts b/src/get-release-info.ts index 11c965f0..201c058e 100644 --- a/src/get-release-info.ts +++ b/src/get-release-info.ts @@ -2,9 +2,11 @@ import type { PackageJson } from "read-pkg"; import { isDefaultRegistry } from "./definitions/constants.js"; import type { PublishContext } from "./definitions/context.js"; +import type { PluginConfig } from "./definitions/pluginConfig.js"; export function getReleaseInfo( { name }: PackageJson, + { mainWorkspace }: PluginConfig, { nextRelease: { version }, }: { @@ -17,7 +19,7 @@ export function getReleaseInfo( return { name: `npm package (@${distTag} dist-tag)`, url: isDefaultRegistry(registry) - ? `https://www.npmjs.com/package/${name}/v/${version}` + ? `https://www.npmjs.com/package/${mainWorkspace ?? name}/v/${version}` : undefined, channel: distTag, }; diff --git a/src/index.ts b/src/index.ts index a337c93f..fd81f730 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,10 @@ export async function verifyConditions( pluginConfig.pkgRoot, publishPlugin.pkgRoot ); + pluginConfig.mainWorkspace = _.defaultTo( + pluginConfig.mainWorkspace, + publishPlugin.mainWorkspace + ); } await verify(pluginConfig, context); diff --git a/src/publish.ts b/src/publish.ts index e68dd8ab..1c57ba5b 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -22,7 +22,7 @@ export async function publish( nextRelease: { version, channel }, logger, } = context; - const { pkgRoot } = pluginConfig; + const { pkgRoot, mainWorkspace } = pluginConfig; const execa = await getImplementation("execa"); if (shouldPublish(pluginConfig, pkg)) { @@ -36,7 +36,7 @@ export async function publish( : []; logger.log( - `Publishing version ${version} to npm registry ${registry} on dist-tag ${distTag}` + `Publishing version ${version} to npm registry ${registry} (tagged as @${distTag})` ); const result = execa( "yarn", @@ -51,10 +51,12 @@ export async function publish( await result; logger.log( - `Published ${pkg.name}@${version} on ${registry} (with tag @${distTag})` + `Published ${ + mainWorkspace ?? pkg.name + }@${version} on ${registry} (tagged as @${distTag})` ); - return getReleaseInfo(pkg, context, distTag, registry); + return getReleaseInfo(pkg, pluginConfig, context, distTag, registry); } const reason = reasonToNotPublish(pluginConfig, pkg); diff --git a/src/verify-config.ts b/src/verify-config.ts index eb8655da..75e62211 100644 --- a/src/verify-config.ts +++ b/src/verify-config.ts @@ -9,30 +9,24 @@ const VALIDATORS = { npmPublish: _.isBoolean, tarballDir: isNonEmptyString, pkgRoot: isNonEmptyString, + mainWorkspace: isNonEmptyString, }; -export function verifyConfig({ - npmPublish, - tarballDir, - pkgRoot, -}: PluginConfig) { - const errors = Object.entries({ npmPublish, tarballDir, pkgRoot }).reduce( - (errors, [option, value]) => { - if (_.isNil(value)) { - return errors; - } +export function verifyConfig(config: PluginConfig) { + const errors = Object.entries(config).reduce((errors, [option, value]) => { + if (_.isNil(value)) { + return errors; + } - if (VALIDATORS[option as keyof PluginConfig](value)) { - return errors; - } + if (VALIDATORS[option as keyof PluginConfig](value)) { + return errors; + } - return [ - ...errors, - getError(`EINVALID${option.toUpperCase()}` as any, { [option]: value }), - ]; - }, - [] as ErrorDefinition[] - ); + return [ + ...errors, + getError(`EINVALID${option.toUpperCase()}` as any, { [option]: value }), + ]; + }, [] as ErrorDefinition[]); return errors; } diff --git a/src/yarn-workspaces.ts b/src/yarn-workspaces.ts new file mode 100644 index 00000000..5e3485a6 --- /dev/null +++ b/src/yarn-workspaces.ts @@ -0,0 +1,21 @@ +import { getImplementation } from "./container.js"; +import type { CommonContext } from "./definitions/context.js"; + +export async function getWorkspaces({ cwd }: { cwd: CommonContext["cwd"] }) { + const execa = await getImplementation("execa"); + + const { stdout } = await execa( + "yarn", + ["workspaces", "list", "--json", "--no-private"], + { + cwd, + } + ); + + return stdout + .split("\n") + .reduce( + (acc, line) => [...acc, JSON.parse(line)], + [] as { location: string; name: string }[] + ); +} diff --git a/test/get-release-info.test.ts b/test/get-release-info.test.ts index 605908f8..32923825 100644 --- a/test/get-release-info.test.ts +++ b/test/get-release-info.test.ts @@ -5,6 +5,7 @@ test("Default registry and scoped module", (t) => { t.deepEqual( getReleaseInfo( { name: "@scope/module" }, + {}, { env: {}, nextRelease: { version: "1.0.0" } }, "latest", "https://registry.npmjs.org/" @@ -21,6 +22,7 @@ test("Custom registry and scoped module", (t) => { t.deepEqual( getReleaseInfo( { name: "@scope/module" }, + {}, { env: {}, nextRelease: { version: "1.0.0" } }, "latest", "https://custom.registry.org/" @@ -32,3 +34,20 @@ test("Custom registry and scoped module", (t) => { } ); }); + +test("With mainWorkspace set", (t) => { + t.deepEqual( + getReleaseInfo( + { name: "@scope/module" }, + { mainWorkspace: "custom-workspace" }, + { env: {}, nextRelease: { version: "1.0.0" } }, + "latest", + "https://registry.npmjs.org/" + ), + { + name: "npm package (@latest dist-tag)", + url: "https://www.npmjs.com/package/custom-workspace/v/1.0.0", + channel: "latest", + } + ); +}); diff --git a/test/integration.test.ts b/test/integration.test.ts index fc7d6708..06ab749c 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1083,81 +1083,78 @@ test("Publish monorepo packages on a dist-tag", async (t) => { ); }); -test.failing( - "Publish monorepo packages and add to default dist-tag", - async (t) => { - const context = createContext(); - const { cwd } = context; - const env = authEnv; - const packagePath = resolve(cwd, "package.json"); - const workspaceAPath = resolve(cwd, "workspace-a", "package.json"); - const workspaceBPath = resolve(cwd, "workspace-b", "package.json"); - await fs.outputJson(packagePath, { - name: "monorepo-add-channel", - private: true, - workspaces: ["workspace-a", "workspace-b"], - }); - await fs.outputJson(workspaceAPath, { - name: "monorepo-add-channel-workspace-a", - version: "0.0.0-dev", - publishConfig: { registry: url }, - }); - await fs.outputJson(workspaceBPath, { - name: "monorepo-add-channel-workspace-b", - version: "0.0.0-dev", - publishConfig: { registry: url }, - }); - await execa("yarn", ["install", "--no-immutable"], { cwd }); - - await mod.publish( - {}, - { - ...context, - env, - options: {}, - releases: [], - commits: [], - lastRelease: { version: "0.0.0" }, - nextRelease: { channel: "next", version: "1.0.0" }, - } - ); +test("Publish monorepo packages and add to default dist-tag", async (t) => { + const context = createContext(); + const { cwd } = context; + const env = authEnv; + const packagePath = resolve(cwd, "package.json"); + const workspaceAPath = resolve(cwd, "workspace-a", "package.json"); + const workspaceBPath = resolve(cwd, "workspace-b", "package.json"); + await fs.outputJson(packagePath, { + name: "monorepo-add-channel", + private: true, + workspaces: ["workspace-a", "workspace-b"], + }); + await fs.outputJson(workspaceAPath, { + name: "monorepo-add-channel-workspace-a", + version: "0.0.0-dev", + publishConfig: { registry: url }, + }); + await fs.outputJson(workspaceBPath, { + name: "monorepo-add-channel-workspace-b", + version: "0.0.0-dev", + publishConfig: { registry: url }, + }); + await execa("yarn", ["install", "--no-immutable"], { cwd }); - const result = await mod.addChannel( - {}, - { - ...context, - env, - options: {}, - releases: [], - commits: [], - lastRelease: { version: "0.0.0" }, - currentRelease: { version: "1.0.0" }, - nextRelease: { version: "1.0.0" }, - } - ); - - t.deepEqual(result, { - name: "npm package (@latest dist-tag)", - url: "https://www.npmjs.com/package/monorepo-add-channel/v/1.0.0", - channel: "latest", - }); - await t.throwsAsync(getPackageTags("monorepo-add-channel", { cwd, env })); - t.is( - (await getPackageTags("monorepo-add-channel-workspace-a", { cwd, env }))[ - "latest" - ], - "1.0.0" - ); - t.is( - (await getPackageTags("monorepo-add-channel-workspace-b", { cwd, env }))[ - "latest" - ], - "1.0.0" - ); - } -); - -test.failing("Publish monorepo packages and add to lts dist-tag", async (t) => { + await mod.publish( + {}, + { + ...context, + env, + options: {}, + releases: [], + commits: [], + lastRelease: { version: "0.0.0" }, + nextRelease: { channel: "next", version: "1.0.0" }, + } + ); + + const result = await mod.addChannel( + {}, + { + ...context, + env, + options: {}, + releases: [], + commits: [], + lastRelease: { version: "0.0.0" }, + currentRelease: { version: "1.0.0" }, + nextRelease: { version: "1.0.0" }, + } + ); + + t.deepEqual(result, { + name: "npm package (@latest dist-tag)", + url: "https://www.npmjs.com/package/monorepo-add-channel/v/1.0.0", + channel: "latest", + }); + await t.throwsAsync(getPackageTags("monorepo-add-channel", { cwd, env })); + t.is( + (await getPackageTags("monorepo-add-channel-workspace-a", { cwd, env }))[ + "latest" + ], + "1.0.0" + ); + t.is( + (await getPackageTags("monorepo-add-channel-workspace-b", { cwd, env }))[ + "latest" + ], + "1.0.0" + ); +}); + +test("Publish monorepo packages and add to lts dist-tag", async (t) => { const context = createContext(); const { cwd } = context; const env = authEnv; diff --git a/test/verify-config.test.ts b/test/verify-config.test.ts index 896247bc..543624c7 100644 --- a/test/verify-config.test.ts +++ b/test/verify-config.test.ts @@ -1,9 +1,14 @@ import test from "ava"; import { verifyConfig } from "../src/verify-config.js"; -test('Verify "npmPublish", "tarballDir" and "pkgRoot" options', async (t) => { +test("Verify options", async (t) => { t.deepEqual( - verifyConfig({ npmPublish: true, tarballDir: "release", pkgRoot: "dist" }), + verifyConfig({ + npmPublish: true, + tarballDir: "release", + pkgRoot: "dist", + mainWorkspace: "cli", + }), [] ); }); @@ -35,22 +40,36 @@ test('Return SemanticReleaseError if "pkgRoot" option is not a String', async (t t.is(error!.code, "EINVALIDPKGROOT"); }); +test('Return SemanticReleaseError if "mainWorkspace" option is not a String', async (t) => { + const mainWorkspace = 42; + const [error, ...errors] = verifyConfig({ mainWorkspace } as any); + + t.is(errors.length, 0); + t.is(error!.name, "SemanticReleaseError"); + t.is(error!.code, "EINVALIDMAINWORKSPACE"); +}); + test("Return SemanticReleaseError Array if multiple config are invalid", async (t) => { const npmPublish = 42; const tarballDir = 42; const pkgRoot = 42; - const [error1, error2, error3] = verifyConfig({ + const mainWorkspace = 42; + const errors = verifyConfig({ npmPublish, tarballDir, pkgRoot, + mainWorkspace, } as any); - t.is(error1!.name, "SemanticReleaseError"); - t.is(error1!.code, "EINVALIDNPMPUBLISH"); + t.is(errors[0]!.name, "SemanticReleaseError"); + t.is(errors[0]!.code, "EINVALIDNPMPUBLISH"); + + t.is(errors[1]!.name, "SemanticReleaseError"); + t.is(errors[1]!.code, "EINVALIDTARBALLDIR"); - t.is(error2!.name, "SemanticReleaseError"); - t.is(error2!.code, "EINVALIDTARBALLDIR"); + t.is(errors[2]!.name, "SemanticReleaseError"); + t.is(errors[2]!.code, "EINVALIDPKGROOT"); - t.is(error3!.name, "SemanticReleaseError"); - t.is(error3!.code, "EINVALIDPKGROOT"); + t.is(errors[3]!.name, "SemanticReleaseError"); + t.is(errors[3]!.code, "EINVALIDMAINWORKSPACE"); }); diff --git a/test/yarn-workspaces.test.ts b/test/yarn-workspaces.test.ts new file mode 100644 index 00000000..4808be2f --- /dev/null +++ b/test/yarn-workspaces.test.ts @@ -0,0 +1,22 @@ +import test from "ava"; +import { getWorkspaces } from "../src/yarn-workspaces.js"; +import { createContext } from "./helpers/create-context.js"; +import { mockExeca } from "./helpers/create-execa-implementation.js"; + +test.serial("getWorkspaces", async (t) => { + const context = createContext(); + + mockExeca({ + stdout: `{"location":".","name":"@mokr/root"} +{"location":"packages/cli","name":"moker"} +{"location":"packages/core","name":"@mokr/core"}`, + }); + + const workspaces = await getWorkspaces(context); + + t.deepEqual(workspaces, [ + { location: ".", name: "@mokr/root" }, + { location: "packages/cli", name: "moker" }, + { location: "packages/core", name: "@mokr/core" }, + ]); +});