implement tool for publishing packages from a mono-repo #1
Changes from all commits
a4d2a64
26dbf8f
6d850ef
5f7a9af
0cb521d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
node_modules |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
language: node_js | ||
node_js: | ||
- stable | ||
cache: | ||
yarn: true | ||
directories: | ||
- node_modules | ||
install: | ||
- yarn install | ||
script: | ||
- yarn test |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"name": "private-dep-public-pkg", | ||
"version": "0.0.1", | ||
"workspaces": [ | ||
"packages/*" | ||
] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"name": "@fixture/public-pkg", | ||
"version": "0.0.1", | ||
"dependencies": { | ||
"@fixture/private-dep": "^0.0.1" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"name": "private-dep-public-pkg", | ||
"version": "0.0.1", | ||
"workspaces": [ | ||
"packages/*" | ||
] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"private": true, | ||
"name": "@fixture/private-pkg", | ||
"version": "0.0.1" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"name": "private-dep-public-pkg", | ||
"version": "0.0.1", | ||
"workspaces": [ | ||
"packages/*" | ||
] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
{ | ||
"name": "@fixture/public-dep", | ||
"version": "0.0.1" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"name": "@fixture/public-pkg", | ||
"version": "0.0.1", | ||
"dependencies": { | ||
"@fixture/public-dep": "^0.0.1" | ||
} | ||
} | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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}`); | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No exit code for a successful run? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm going to add a There was a problem hiding this comment. Choose a reason for hiding this commentThe 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`); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feel like the text
|
||
|
||
process.exit(0); | ||
} catch (e) { | ||
console.log(e); | ||
process.exit(1); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
{ | ||
"name": "antwerp", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like it, but why There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, 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" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So this is the step that we don't do with 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", | ||
]); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
}); | ||
|
||
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([]); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
YMMV |
||
} | ||
|
||
function getWorkspaces() { | ||
const pkgJson = JSON.parse(fs.readFileSync("package.json", "utf8")); | ||
return pkgJson.workspaces.reduce((result, wsGlob) => { | ||
return result.concat(glob.sync(wsGlob)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. braces and |
||
}, []); | ||
} | ||
|
||
// 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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Capture that in an issue. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Captured in #3. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}`); | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So this Perhaps Certainly should add a comment that explains what it's doing.
|
||
|
||
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; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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`); | ||
} | ||
} | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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.