Skip to content

Commit

Permalink
feat(version): Create Github releases with --github-release (#1864)
Browse files Browse the repository at this point in the history
Fixes #1513
  • Loading branch information
milesj authored and evocateur committed Feb 8, 2019
1 parent 31da8de commit f84a631
Show file tree
Hide file tree
Showing 19 changed files with 705 additions and 145 deletions.
21 changes: 12 additions & 9 deletions commands/__mocks__/@lerna/conventional-commits.js
Expand Up @@ -2,19 +2,22 @@

const fs = require.requireActual("fs-extra");
const path = require("path");
const semver = require("semver");

const mockConventionalCommits = jest.genMockFromModule("@lerna/conventional-commits");
const mockRecommendVersion = jest.fn().mockName("recommendVersion");
const mockUpdateChangelog = jest.fn().mockName("updateChangelog");

mockConventionalCommits.updateChangelog.mockImplementation(pkg => {
mockRecommendVersion.mockImplementation(node => semver.inc(node.version, "patch"));

mockUpdateChangelog.mockImplementation(pkg => {
const filePath = path.join(pkg.location, "CHANGELOG.md");

// grumble grumble re-implementing the implementation
return fs.outputFile(filePath, "changelog", "utf8").then(() => filePath);
return fs.outputFile(filePath, "changelog", "utf8").then(() => ({
logPath: filePath,
newEntry: pkg.version ? `${pkg.name} - ${pkg.version}` : pkg.name,
}));
});

function mockBumps(...bumps) {
bumps.forEach(bump => mockConventionalCommits.recommendVersion.mockResolvedValueOnce(bump));
}

module.exports = mockConventionalCommits;
module.exports.mockBumps = mockBumps;
exports.recommendVersion = mockRecommendVersion;
exports.updateChangelog = mockUpdateChangelog;
14 changes: 14 additions & 0 deletions commands/__mocks__/@lerna/github-client.js
@@ -0,0 +1,14 @@
"use strict";

const client = {
repos: {
createRelease: jest.fn(),
},
};

module.exports.client = client;
module.exports.createGitHubClient = () => client;
module.exports.parseGitRepo = () => ({
owner: "lerna",
name: "lerna",
});
15 changes: 15 additions & 0 deletions commands/version/README.md
Expand Up @@ -43,6 +43,7 @@ If you have any packages with a prerelease version number (e.g. `2.0.0-beta.3`)
- [`--exact`](#--exact)
- [`--force-publish`](#--force-publish)
- [`--git-remote`](#--git-remote-name)
- [`--github-release`](#--github-release)
- [`--ignore-changes`](#--ignore-changes)
- [`--include-merged-tags`](#--include-merged-tags)
- [`--message`](#--message-msg)
Expand Down Expand Up @@ -159,6 +160,20 @@ lerna version --git-remote upstream

When run with this flag, `lerna version` will push the git changes to the specified remote instead of `origin`.

### `--github-release`

```sh
lerna version --github-release --conventional-commits
```

When run with this flag, `lerna version` will create an official GitHub release based on the changed packages. Requires `--conventional-commits` to be passed so that changelogs can be generated.

To authenticate with GitHub, the following environment variables can be defined.

- `GH_TOKEN` (required) - Your GitHub authentication token (under Settings > Developer settings > Personal access tokens).
- `GHE_API_URL` - When using GitHub Enterprise, an absolute URL to the API.
- `GHE_VERSION` - When using GitHub Enterprise, the currently installed GHE version. [Supports the following versions](https://github.com/octokit/plugin-enterprise-rest.js).

### `--ignore-changes`

Ignore changes in files matched by glob(s) when detecting changed packages.
Expand Down
Expand Up @@ -32,7 +32,7 @@ describe("--conventional-commits", () => {
]);

beforeEach(() => {
ConventionalCommitUtilities.mockBumps(...versionBumps.values());
versionBumps.forEach(bump => ConventionalCommitUtilities.recommendVersion.mockResolvedValueOnce(bump));
});

it("should use conventional-commits utility to guess version bump and generate CHANGELOG", async () => {
Expand Down Expand Up @@ -85,7 +85,12 @@ describe("--conventional-commits", () => {

describe("fixed mode", () => {
beforeEach(() => {
ConventionalCommitUtilities.mockBumps("1.0.1", "1.1.0", "2.0.0", "1.1.0", "1.0.0");
ConventionalCommitUtilities.recommendVersion
.mockResolvedValueOnce("1.0.1")
.mockResolvedValueOnce("1.1.0")
.mockResolvedValueOnce("2.0.0")
.mockResolvedValueOnce("1.1.0")
.mockResolvedValueOnce("1.0.0");
});

it("should use conventional-commits utility to guess version bump and generate CHANGELOG", async () => {
Expand Down
107 changes: 107 additions & 0 deletions commands/version/__tests__/version-github-release.test.js
@@ -0,0 +1,107 @@
"use strict";

// local modules _must_ be explicitly mocked
jest.mock("../lib/git-add");
jest.mock("../lib/git-commit");
jest.mock("../lib/git-push");
jest.mock("../lib/git-tag");
jest.mock("../lib/is-anything-committed");
jest.mock("../lib/is-behind-upstream");
jest.mock("../lib/remote-branch-exists");

// mocked modules
const { client } = require("@lerna/github-client");
const { recommendVersion } = require("@lerna/conventional-commits");

// helpers
const initFixture = require("@lerna-test/init-fixture")(__dirname);

// test command
const lernaVersion = require("@lerna-test/command-runner")(require("../command"));

test("--github-release does not create a release if --no-push is passed", async () => {
const cwd = await initFixture("independent");

await lernaVersion(cwd)("--github-release", "--conventional-commits", "--no-push");

expect(client.repos.createRelease).not.toHaveBeenCalled();
});

test("--github-release throws an error if --conventional-commits is not passed", async () => {
const cwd = await initFixture("independent");

try {
await lernaVersion(cwd)("--github-release");
} catch (err) {
expect(err.message).toBe("To create a Github Release, you must enable --conventional-commits");
expect(client.repos.createRelease).not.toHaveBeenCalled();
}

expect.hasAssertions();
});

test("--github-release marks a version as a pre-release if it contains a valid part", async () => {
const cwd = await initFixture("normal");

recommendVersion.mockResolvedValueOnce("2.0.0-alpha.1");

await lernaVersion(cwd)("--github-release", "--conventional-commits");

expect(client.repos.createRelease).toHaveBeenCalledTimes(1);
expect(client.repos.createRelease).toHaveBeenCalledWith({
owner: "lerna",
repo: "lerna",
tag_name: "v2.0.0-alpha.1",
name: "v2.0.0-alpha.1",
body: "normal",
draft: false,
prerelease: true,
});
});

test("--github-release creates a release for every independent version", async () => {
const cwd = await initFixture("independent");
const versionBumps = new Map([
["package-1", "1.0.1"],
["package-2", "2.1.0"],
["package-3", "4.0.0"],
["package-4", "4.1.0"],
["package-5", "5.0.1"],
]);

versionBumps.forEach(bump => recommendVersion.mockResolvedValueOnce(bump));

await lernaVersion(cwd)("--github-release", "--conventional-commits");

expect(client.repos.createRelease).toHaveBeenCalledTimes(5);
versionBumps.forEach((version, name) => {
expect(client.repos.createRelease).toHaveBeenCalledWith({
owner: "lerna",
repo: "lerna",
tag_name: `${name}@${version}`,
name: `${name}@${version}`,
body: `${name} - ${version}`,
draft: false,
prerelease: false,
});
});
});

test("--github-release creates a single fixed release", async () => {
const cwd = await initFixture("normal");

recommendVersion.mockResolvedValueOnce("1.1.0");

await lernaVersion(cwd)("--github-release", "--conventional-commits");

expect(client.repos.createRelease).toHaveBeenCalledTimes(1);
expect(client.repos.createRelease).toHaveBeenCalledWith({
owner: "lerna",
repo: "lerna",
tag_name: "v1.1.0",
name: "v1.1.0",
body: "normal",
draft: false,
prerelease: false,
});
});
4 changes: 4 additions & 0 deletions commands/version/command.js
Expand Up @@ -40,6 +40,10 @@ exports.builder = (yargs, composed) => {
requiresArg: true,
defaultDescription: "origin",
},
"github-release": {
describe: "Create an official GitHub release for every version.",
type: "boolean",
},
"ignore-changes": {
describe: [
"Ignore changes in files matched by glob(s) when detecting changed packages.",
Expand Down
70 changes: 65 additions & 5 deletions commands/version/index.js
Expand Up @@ -19,6 +19,7 @@ const collectUpdates = require("@lerna/collect-updates");
const { createRunner } = require("@lerna/run-lifecycle");
const batchPackages = require("@lerna/batch-packages");
const ValidationError = require("@lerna/validation-error");
const { createGitHubClient, parseGitRepo } = require("@lerna/github-client");

const getCurrentBranch = require("./lib/get-current-branch");
const gitAdd = require("./lib/git-add");
Expand Down Expand Up @@ -63,6 +64,16 @@ class VersionCommand extends Command {
this.pushToRemote = gitTagVersion && amend !== true && push;
// never automatically push to remote when amending a commit

this.createReleases = this.pushToRemote && this.options.githubRelease;
this.releaseNotes = [];

if (this.createReleases && this.options.conventionalCommits !== true) {
throw new ValidationError(
"ERELEASE",
"To create a Github Release, you must enable --conventional-commits"
);
}

this.gitOpts = {
amend,
commitHooks,
Expand Down Expand Up @@ -213,6 +224,12 @@ class VersionCommand extends Command {
this.logger.info("execute", "Skipping git push");
}

if (this.createReleases) {
tasks.push(() => this.createGitHubReleases());
} else {
this.logger.info("execute", "Skipping GitHub releases");
}

return pWaterfall(tasks).then(() => {
if (!this.composed) {
this.logger.success("version", "finished");
Expand Down Expand Up @@ -332,7 +349,7 @@ class VersionCommand extends Command {
let highestVersion = this.project.version;

versions.forEach(bump => {
if (semver.gt(bump, highestVersion)) {
if (bump && semver.gt(bump, highestVersion)) {
highestVersion = bump;
}
});
Expand Down Expand Up @@ -450,9 +467,17 @@ class VersionCommand extends Command {
changelogPreset,
rootPath,
tagPrefix: this.tagPrefix,
}).then(changelogLocation => {
}).then(({ logPath, newEntry }) => {
// commit the updated changelog
changedFiles.add(changelogLocation);
changedFiles.add(logPath);

// add release notes
if (independentVersions) {
this.releaseNotes.push({
name: pkg.name,
notes: newEntry,
});
}

return pkg;
})
Expand All @@ -478,9 +503,15 @@ class VersionCommand extends Command {
rootPath,
tagPrefix: this.tagPrefix,
version: this.globalVersion,
}).then(changelogLocation => {
}).then(({ logPath, newEntry }) => {
// commit the updated changelog
changedFiles.add(changelogLocation);
changedFiles.add(logPath);

// add release notes
this.releaseNotes.push({
name: "fixed",
notes: newEntry,
});
})
);
}
Expand Down Expand Up @@ -556,6 +587,35 @@ class VersionCommand extends Command {

return gitPush(this.gitRemote, this.currentBranch, this.execOpts);
}

createGitHubReleases() {
this.logger.info("github", "Creating GitHub releases...");

const client = createGitHubClient();
const repo = parseGitRepo(this.options.gitRemote, this.execOpts);

return Promise.all(
this.releaseNotes.map(({ notes, name }) => {
const tag = name === "fixed" ? this.tags[0] : this.tags.find(t => t.startsWith(name));

if (!tag) {
return Promise.resolve();
}

const prereleaseParts = semver.prerelease(tag.replace(`${name}@`, "")) || [];

return client.repos.createRelease({
owner: repo.owner,
repo: repo.name,
tag_name: tag,
name: tag,
body: notes,
draft: false,
prerelease: prereleaseParts.length > 0,
});
})
);
}
}

module.exports.VersionCommand = VersionCommand;
4 changes: 4 additions & 0 deletions commands/version/lib/__mocks__/git-add.js
@@ -0,0 +1,4 @@
"use strict";

// to mock user modules, you _must_ call `jest.mock('./path/to/module')`
module.exports = jest.fn(() => Promise.resolve());
4 changes: 4 additions & 0 deletions commands/version/lib/__mocks__/git-commit.js
@@ -0,0 +1,4 @@
"use strict";

// to mock user modules, you _must_ call `jest.mock('./path/to/module')`
module.exports = jest.fn(() => Promise.resolve());
4 changes: 4 additions & 0 deletions commands/version/lib/__mocks__/git-tag.js
@@ -0,0 +1,4 @@
"use strict";

// to mock user modules, you _must_ call `jest.mock('./path/to/module')`
module.exports = jest.fn(() => Promise.resolve());
1 change: 1 addition & 0 deletions commands/version/package.json
Expand Up @@ -39,6 +39,7 @@
"@lerna/collect-updates": "file:../../utils/collect-updates",
"@lerna/command": "file:../../core/command",
"@lerna/conventional-commits": "file:../../core/conventional-commits",
"@lerna/github-client": "file:../../utils/github-client",
"@lerna/output": "file:../../utils/output",
"@lerna/prompt": "file:../../core/prompt",
"@lerna/run-lifecycle": "file:../../utils/run-lifecycle",
Expand Down

0 comments on commit f84a631

Please sign in to comment.