Skip to content

Commit

Permalink
feat(publish): Add a "from-package" positional argument (#1708)
Browse files Browse the repository at this point in the history
Publish un-published releases by reading versions from the package.json files and publishing any that are not already available in the registry.

Fixes #1648
  • Loading branch information
chriscasola authored and evocateur committed Nov 29, 2018
1 parent 6107c9a commit 16611be
Show file tree
Hide file tree
Showing 9 changed files with 188 additions and 9 deletions.
9 changes: 9 additions & 0 deletions commands/publish/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
```sh
lerna publish # publish packages that have changed since the last release
lerna publish from-git # explicitly publish packages tagged in current commit
lerna publish from-package # explicitly publish packages where the latest version is not present in the registry
```

When run, this command does one of the following things:

* Publish packages updated since the last release (calling [`lerna version`](https://github.com/lerna/lerna/tree/master/commands/version#readme) behind the scenes).
* This is the legacy behavior of lerna 2.x
* Publish packages tagged in the current commit (`from-git`).
* Publish packages in the latest commit where the version is not present in the registry (`from-package`).
* Publish an unversioned "canary" release of packages (and their dependents) updated in the previous commit.

> Lerna will never publish packages which are marked as private (`"private": true` in the `package.json`).
Expand All @@ -36,6 +38,13 @@ This will identify packages tagged by `lerna version` and publish them to npm.
This is useful in CI scenarios where you wish to manually increment versions,
but have the package contents themselves consistently published by an automated process.

### bump `from-package`

Similar to the `from-git` keyword except the list of packages to publish is determined by inspecting each `package.json`
and determining if any package version is not present in the registry. Any versions not present in the registry will
be published.
This is useful when a previous `lerna publish` failed to publish all packages to the registry.

## Options

`lerna publish` supports all of the options provided by [`lerna version`](https://github.com/lerna/lerna/tree/master/commands/version#options) in addition to the following:
Expand Down
34 changes: 34 additions & 0 deletions commands/publish/__tests__/get-unpublished-packages.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use strict";

jest.mock("pacote");

const pacote = require("pacote");
const Project = require("@lerna/project");
const initFixture = require("@lerna-test/init-fixture")(__dirname);
const getUnpublishedPackages = require("../lib/get-unpublished-packages");

pacote.packument.mockImplementation(async pkg => {
if (pkg === "package-1") {
return {
versions: {},
};
}

if (pkg === "package-2") {
return {
versions: {
"1.0.0": {},
},
};
}

throw new Error("package does not exist");
});

test("getUnpublishedPackages", async () => {
const cwd = await initFixture("licenses-names");
const project = new Project(cwd);

const pkgs = await getUnpublishedPackages(project, {});
expect(pkgs.map(p => p.name)).toEqual(["package-1", "package-3", "package-4", "package-5"]);
});
80 changes: 74 additions & 6 deletions commands/publish/__tests__/publish-command.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
jest.mock("../lib/get-packages-without-license");
jest.mock("../lib/verify-npm-package-access");
jest.mock("../lib/get-npm-username");
jest.mock("../lib/get-unpublished-packages");
// FIXME: better mock for version command
jest.mock("../../version/lib/git-push");
jest.mock("../../version/lib/is-anything-committed");
Expand All @@ -19,6 +20,8 @@ const output = require("@lerna/output");
const checkWorkingTree = require("@lerna/check-working-tree");
const getNpmUsername = require("../lib/get-npm-username");
const verifyNpmPackageAccess = require("../lib/verify-npm-package-access");
const getUnpublishedPackages = require("../lib/get-unpublished-packages");
const Project = require("@lerna/project");

// helpers
const loggingOutput = require("@lerna-test/logging-output");
Expand Down Expand Up @@ -46,14 +49,16 @@ describe("PublishCommand", () => {
expect(verifyNpmPackageAccess).not.toBeCalled();
});

it("exits early when no changes found from-git", async () => {
collectUpdates.setUpdated(cwd);
["from-git", "from-package"].forEach(fromArg => {
it(`exits early when no changes found ${fromArg}`, async () => {
collectUpdates.setUpdated(cwd);

await lernaPublish(cwd)("from-git");
await lernaPublish(cwd)(fromArg);

const logMessages = loggingOutput("success");
expect(logMessages).toContain("No changed packages to publish");
expect(verifyNpmPackageAccess).not.toBeCalled();
const logMessages = loggingOutput("success");
expect(logMessages).toContain("No changed packages to publish");
expect(verifyNpmPackageAccess).not.toBeCalled();
});
});

it("exits non-zero with --scope", async () => {
Expand Down Expand Up @@ -243,6 +248,69 @@ Set {
});
});

describe("from-package", () => {
it("publishes unpublished packages", async () => {
const testDir = await initFixture("normal");
const project = new Project(testDir);

getUnpublishedPackages.mockImplementationOnce(async () => {
const pkgs = await project.getPackages();
return pkgs.slice(1, 3);
});

await lernaPublish(testDir)("from-package");

expect(PromptUtilities.confirm).lastCalledWith("Are you sure you want to publish these packages?");
expect(output.logged()).toMatch("Found 2 packages to publish:");
expect(npmPublish.order()).toEqual(["package-2", "package-3"]);
});

it("publishes unpublished independent packages", async () => {
const testDir = await initFixture("independent");
const project = new Project(testDir);

getUnpublishedPackages.mockImplementationOnce(() => project.getPackages());

await lernaPublish(testDir)("from-package");

expect(npmPublish.order()).toEqual([
"package-1",
"package-3",
"package-4",
"package-2",
// package-5 is private
]);
});

it("exits early when all packages are published", async () => {
const testDir = await initFixture("normal");

await lernaPublish(testDir)("from-package");

expect(npmPublish).not.toBeCalled();

const logMessages = loggingOutput("info");
expect(logMessages).toContain("No unpublished release found");
});

it("throws an error when uncommitted changes are present", async () => {
checkWorkingTree.throwIfUncommitted.mockImplementationOnce(() => {
throw new Error("uncommitted");
});

const testDir = await initFixture("normal");

try {
await lernaPublish(testDir)("from-package");
} catch (err) {
expect(err.message).toBe("uncommitted");
// notably different than the actual message, but good enough here
}

expect.assertions(1);
});
});

describe("--registry", () => {
it("passes registry to npm commands", async () => {
const testDir = await initFixture("normal");
Expand Down
2 changes: 1 addition & 1 deletion commands/publish/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ exports.handler = function handler(argv) {
};

function composeVersionOptions(yargs) {
versionCommand.addBumpPositional(yargs, ["from-git"]);
versionCommand.addBumpPositional(yargs, ["from-git", "from-package"]);
versionCommand.builder(yargs, "publish");

return yargs;
Expand Down
34 changes: 32 additions & 2 deletions commands/publish/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const versionCommand = require("@lerna/version");
const createTempLicenses = require("./lib/create-temp-licenses");
const getCurrentSHA = require("./lib/get-current-sha");
const getCurrentTags = require("./lib/get-current-tags");
const getUnpublishedPackages = require("./lib/get-unpublished-packages");
const getNpmUsername = require("./lib/get-npm-username");
const getTaggedPackages = require("./lib/get-tagged-packages");
const getPackagesWithoutLicense = require("./lib/get-packages-without-license");
Expand Down Expand Up @@ -133,8 +134,9 @@ class PublishCommand extends Command {
: [this.packagesToPublish];

if (result.needsConfirmation) {
// only confirm for --canary or bump === "from-git",
// as VersionCommand has its own confirmation prompt
// only confirm for --canary, bump === "from-git",
// or bump === "from-package", as VersionCommand
// has its own confirmation prompt
return this.confirmPublish();
}

Expand Down Expand Up @@ -184,6 +186,8 @@ class PublishCommand extends Command {

if (this.options.bump === "from-git") {
chain = chain.then(() => this.detectFromGit());
} else if (this.options.bump === "from-package") {
chain = chain.then(() => this.detectFromPackage());
} else if (this.options.canary) {
chain = chain.then(() => this.detectCanaryVersions());
} else {
Expand Down Expand Up @@ -232,6 +236,32 @@ class PublishCommand extends Command {
});
}

detectFromPackage() {
let chain = Promise.resolve();

// attempting to publish a release with local changes is not allowed
chain = chain.then(() => this.verifyWorkingTreeClean());

chain = chain.then(() => getUnpublishedPackages(this.project, this.conf));
chain = chain.then(unpublishedPackages => {
if (!unpublishedPackages.length) {
this.logger.notice("from-package", "No unpublished release found");
}

return unpublishedPackages.map(({ name }) => this.packageGraph.get(name));
});

return chain.then(updates => {
const updatesVersions = updates.map(({ pkg }) => [pkg.name, pkg.version]);

return {
updates,
updatesVersions,
needsConfirmation: true,
};
});
}

detectCanaryVersions() {
const {
bump = "prepatch",
Expand Down
3 changes: 3 additions & 0 deletions commands/publish/lib/__mocks__/get-unpublished-packages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"use strict";

module.exports = jest.fn(() => Promise.resolve([]));
33 changes: 33 additions & 0 deletions commands/publish/lib/get-unpublished-packages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use strict";

const log = require("npmlog");
const pReduce = require("p-reduce");
const pacote = require("pacote");

module.exports = getUnpublishedPackages;

function getUnpublishedPackages(project, opts) {
log.silly("getPackageVersions");

let chain = Promise.resolve();

const mapper = (unpublished, pkg) =>
pacote.packument(pkg.name, opts.snapshot).then(
packument => {
if (packument.versions[pkg.version] === undefined) {
unpublished.push(pkg);
}

return unpublished;
},
() => {
log.warn("", "Unable to determine published versions, assuming unpublished.");
return unpublished.concat([pkg]);
}
);

chain = chain.then(() => project.getPackages());
chain = chain.then(packages => pReduce(packages, mapper, []));

return chain;
}
1 change: 1 addition & 0 deletions commands/publish/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"p-map": "^1.2.0",
"p-pipe": "^1.2.0",
"p-reduce": "^1.0.0",
"pacote": "^9.1.0",
"semver": "^5.5.0"
}
}
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 16611be

Please sign in to comment.