Skip to content
This repository has been archived by the owner on Dec 5, 2018. It is now read-only.

implement tool for publishing packages from a mono-repo #1

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
@@ -0,0 +1 @@
node_modules
11 changes: 11 additions & 0 deletions .travis.yml
@@ -0,0 +1,11 @@
language: node_js
node_js:
- stable
cache:
yarn: true
directories:
- node_modules
install:
- yarn install
script:
- yarn test
7 changes: 7 additions & 0 deletions fixtures/private-dep-public-pkg/package.json
@@ -0,0 +1,7 @@
{
"name": "private-dep-public-pkg",
"version": "0.0.1",
"workspaces": [
"packages/*"
]
}
@@ -0,0 +1,7 @@
{
"name": "@fixture/public-pkg",
"version": "0.0.1",
"dependencies": {
"@fixture/private-dep": "^0.0.1"
}
}
7 changes: 7 additions & 0 deletions fixtures/private-pkg/package.json
@@ -0,0 +1,7 @@
{
"name": "private-dep-public-pkg",
"version": "0.0.1",
"workspaces": [
"packages/*"
]
}
5 changes: 5 additions & 0 deletions fixtures/private-pkg/packages/private-pkg/package.json
@@ -0,0 +1,5 @@
{
"private": true,
"name": "@fixture/private-pkg",
"version": "0.0.1"
}
7 changes: 7 additions & 0 deletions fixtures/public-dep-public-pkg/package.json
@@ -0,0 +1,7 @@
{
"name": "private-dep-public-pkg",
"version": "0.0.1",
"workspaces": [
"packages/*"
]
}
@@ -0,0 +1,4 @@
{
"name": "@fixture/public-dep",
"version": "0.0.1"
}
@@ -0,0 +1,7 @@
{
"name": "@fixture/public-pkg",
"version": "0.0.1",
"dependencies": {
"@fixture/public-dep": "^0.0.1"
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love all these test fixtures. Is @fixture a reserved namespace or just something you chose to use just for testing?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just something I made up fro testing... nothing actually gets published as part of the test tests.

37 changes: 37 additions & 0 deletions index.js
@@ -0,0 +1,37 @@
#!/usr/bin/env node
const child_process = require("child_process");
const minimist = require("minimist");

const {runChecks, getCommands} = require("./src/utils");

var argv = require('minimist')(process.argv.slice(2));

try {
runChecks();
const commands = getCommands();
if (commands.length === 0) {
console.log("all packages up to date");
process.exit(0);
}

for (const command of commands) {
if (!argv["dry-run"]) {
console.log(`running: ${command}`);
child_process.execSync(command, {encoding: "utf8"});
} else {
console.log(`would run: ${command}`);
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No exit code for a successful run?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to add a process.exit(0); but it has to come right at the end of the try block.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or after the catch?


if (!argv["dry-run"]) {
console.log("pushing tags");
child_process.execSync("git push --tags");
} else {
console.log(`would run: git push --tags`);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel like the text git push --tags should be in a constant, and then there should be an maybeRunCommand command that takes that string like this:

function (command, description) {
    console.log(description);
    const isDryRun = argv["dry-run"];
    if (isDryRun) {
        console.log(`would run: ${command}`);
    } else {
        child_process.execSync(command);
    }
}

That way we can reuse this for any command.


process.exit(0);
} catch (e) {
console.log(e);
process.exit(1);
}
22 changes: 22 additions & 0 deletions package.json
@@ -0,0 +1,22 @@
{
"name": "antwerp",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it, but why antwerp?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming things is hard. I started out with pubtools but then I thought I'd change it to pubtool since this is really only one tool, but that's already taken. I started thinking about pubs I've been in in videos games and there was one in Hero's Quest I (aka Quest for Glory I) where you have to say a secret password to the goon guarding the entrance to the thieves' guild. One of the passwords was antwerp. I'm open to better names as this name is not very descriptive and only tangentially related to the task that the tool performs.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, antwerp is the name of this creature in the game:
6c45017fb313c6dd0445afbecab640a7 gif

In my journey through Benelux in the spring I visited Antwerp the city which has this really old wooden escalator which leads to a tunnel that you can walk, bike, motorbike through. Unsurprisingly it smells really woody. If you're ever in Antwerp it's a must see... along with the MAS.

"version": "0.0.1",
"description": "A tool for publishing multiple packages in mono-repos",
"main": "index.js",
"scripts": {
"test": "jest"
},
"author": "",
"license": "MIT",
"dependencies": {
"fs-extra": "^6.0.1",
"glob": "^7.1.2",
"jest": "^23.1.0",
"minimist": "^1.2.0",
"semver": "^5.5.0",
"tmp": "0.0.33"
},
"bin": {
"antwerp": "index.js"
}
}
130 changes: 130 additions & 0 deletions src/__test__/utils.test.js
@@ -0,0 +1,130 @@
const child_process = require("child_process");
const tmp = require("tmp");
const { copySync } = require("fs-extra");
const path = require("path");
const fs = require("fs");
const semver = require("semver");

const { runChecks, getTags, getCommands, getWorkspaces } = require("../utils");

function addTag(tag) {
child_process.execSync(`git tag ${tag}`);
}

function bumpVersion(pkgPath, release) {
const pkgJsonPath = path.join(pkgPath, "package.json");
const json = JSON.parse(fs.readFileSync(pkgJsonPath));
const newVersion = semver.inc(json.version, release);
json.version = newVersion;
fs.writeFileSync(pkgJsonPath, JSON.stringify(json, null, 4));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is the step that we don't do with antwerp. antwerp assumes you've done all the version changes that you need to and then just ensure that things are tagged with those versions before publish, after asserting that the dependencies are available for those packages.

Is that right?


function getPackages() {
const packages = {};

const workspaces = getWorkspaces();

for (const workspace of workspaces) {
const pkgPath = path.join(workspace, "package.json");
if (!fs.existsSync(pkgPath)) {
throw new Error(`no package.json for ${ws}`);
}
// TODO: validate the package.json
const pkgJson = JSON.parse(fs.readFileSync(pkgPath));
// TODO: store the paths separately
pkgJson.__path__ = pkgPath;
packages[pkgJson.name] = pkgJson;
}

return packages;
}

// TODO: use before/after to clean things up properly
function runTest(fixture, name, func) {
test(`${fixture} ${name}`, () => {
const tmpObj = tmp.dirSync({ unsafeCleanup: true });
copySync(path.join("fixtures", fixture), tmpObj.name);

const cwd = process.cwd();
process.chdir(tmpObj.name);

child_process.execSync("git init");
child_process.execSync("git add .");
child_process.execSync(`git commit -a -m "initial commit"`);

func();

process.chdir(cwd);
tmpObj.removeCallback();
});
}

describe("checks", () => {
runTest("private-dep-public-pkg", "should fail", () => {
expect(() => {
runChecks();
}).toThrow();
});

runTest("public-dep-public-pkg", "should not fail", () => {
expect(() => {
runChecks();
}).not.toThrow();
});
});

describe("tags", () => {
runTest("public-dep-public-pkg", "should have no tags by default", () => {
const tags = getTags();
expect(tags).toEqual([]);
});

runTest("public-dep-public-pkg", "should add all tags", () => {
expect(getTags()).toEqual([]);
expect(getCommands()).toEqual([
"git tag @fixture/public-dep@0.0.1",
"npm publish --access=public packages/public-dep",
"git tag @fixture/public-pkg@0.0.1",
"npm publish --access=public packages/public-pkg",
]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the ease with which we can test this without necessarily having to run it fully although there's no substitute for actually running and checking those outputted commands really work.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's tough b/c there isn't a good way to mock out npm. At least with git I can run commands locally and verify things about the state of the local repo.

});

runTest("public-dep-public-pkg", "should add all tags", () => {
expect(getTags()).toEqual([]);
addTag("@fixture/public-dep@0.0.1");
addTag("@fixture/public-pkg@0.0.1");
bumpVersion("packages/public-dep", "patch");
expect(getCommands()).toEqual([
"git tag @fixture/public-dep@0.0.2",
"npm publish --access=public packages/public-dep",
]);
});
});

describe("packages", () => {
runTest("public-dep-public-pkg", "should get package json", () => {
const packages = getPackages();
expect(Object.keys(packages)).toEqual([
"@fixture/public-dep",
"@fixture/public-pkg",
]);
expect(packages["@fixture/public-dep"].version).toEqual("0.0.1");
expect(packages["@fixture/public-pkg"].version).toEqual("0.0.1");
});

runTest("public-dep-public-pkg", "should bump versions", () => {
expect(getPackages()["@fixture/public-dep"].version).toEqual("0.0.1");
bumpVersion("packages/public-dep", "patch");
expect(getPackages()["@fixture/public-dep"].version).toEqual("0.0.2");
bumpVersion("packages/public-dep", "patch");
expect(getPackages()["@fixture/public-dep"].version).toEqual("0.0.3");
bumpVersion("packages/public-dep", "minor");
expect(getPackages()["@fixture/public-dep"].version).toEqual("0.1.0");
bumpVersion("packages/public-dep", "major");
expect(getPackages()["@fixture/public-dep"].version).toEqual("1.0.0");
});

runTest("private-pkg", "should not be published", () => {
expect(getCommands()).toEqual([]);
});
});
94 changes: 94 additions & 0 deletions src/utils.js
@@ -0,0 +1,94 @@
const fs = require("fs");
const child_process = require("child_process");
const path = require("path");
const glob = require("glob");
const semver = require("semver");

function getTags() {
return child_process.execSync(`git tag`, {encoding: "utf8"}).split("\n").filter(Boolean);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find these chains easier to read if they are lined up by the dots.

    return child_process
        .execSync...
        .split...
        .filter...

YMMV

}

function getWorkspaces() {
const pkgJson = JSON.parse(fs.readFileSync("package.json", "utf8"));
return pkgJson.workspaces.reduce((result, wsGlob) => {
return result.concat(glob.sync(wsGlob));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

braces and return are redundant.

}, []);
}

// We assume that if a there's a tag for the current version of a package that
// it's been published already. In the future we should check if that version
// exists on npm and attempt to republish the package if it isn't.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capture that in an issue.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Captured in #3.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can update the comment to link back to the GitHub issue.

// See https://github.com/Khan/antwerp/issues/3.
function getCommands() {
const commands = [];
const allTags = getTags();
for (const workspace of getWorkspaces()) {
const pkgPath = path.join(workspace, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (pkg.private) {
continue;
}
const tag = `${pkg.name}@${pkg.version}`;
if (!allTags.includes(tag)) {
commands.push(`git tag ${tag}`);
// We use npm instead of yarn for publishing b/c yarn publish
// bumps the version automatically
commands.push(`npm publish --access=public ${workspace}`);
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this getCommands call is actually doing some useful stuff. I wonder if it is clear from the name.

Perhaps generateCommandList or something would be better?

Certainly should add a comment that explains what it's doing.

  • Returns command list for tagging and publishing all public packages


return commands;
}

function runChecks(verbose = false) {
const execOptions = {
encoding: "utf8",
};

const pkgVerMap = {};
const depMap = {};
const pkgJsonMap = {};

for (const workspace of getWorkspaces()) {
const pkgPath = path.join(workspace, "package.json");
if (!fs.existsSync(pkgPath)) {
throw new Error(`no package.json for ${workspace}`);
}
const pkgJson = JSON.parse(fs.readFileSync(pkgPath));
pkgVerMap[pkgJson.name] = pkgJson.version;
depMap[pkgJson.name] = pkgJson.dependencies || {};
pkgJsonMap[pkgJson.name] = pkgJson;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, this loop is ensuring that even though the dependency may not exist yet (because it's on a version of the package that isn't released), it will when antwerp is finished. Right?

Can we get this loop in its own method to make that a little clearer?


for (const [pkgName, deps] of Object.entries(depMap)) {
for (const depName of Object.keys(deps)) {
// Check that dependency exists
// TODO: handle external dependencies
if (!pkgVerMap.hasOwnProperty(depName)) {
throw new Error(`${pkgName} has dep ${depName} which doesn't exist`);
}

// Check that depedency versions are satisified
// TODO: fallback to check if any tags exist that would satisfy the dep
if (!semver.satisfies(pkgVerMap[depName], deps[depName])) {
throw new Error(`${depName}@${pkgVerMap[depName]} doesn't satisfy ${depName}@${deps[depName]}`)
}

// Check to see if any dependencies of public packages are private
if (!pkgJsonMap[pkgName].private && pkgJsonMap[depName].private) {
throw new Error(`${pkgName} is public but ${depName} is private`);
}

if (verbose) {
console.log(`INFO: deps for ${pkgName} satisfied`);
}
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this needs to be its own function too, I think. Either the whole loop or its contents.


module.exports = {
runChecks,
getTags,
getCommands,
getWorkspaces,
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any thoughts on supporting use-cases that want to consume this as an API versus a tool?