lerna publish
lerna publish from-git
lerna publish from-packages
官方文档,lerna publish
一共有这样几种执行表现形式:
lerna publish 永远不会发布 package.json 中 private 设置为 true 的包
发布自上次发布来有更新的包(这里的上次发布也是基于上次执行lerna publish
而言)
发布在当前 commit 上打上了 annotated tag 的包(即 lerna publish from-git
)
发布在最近 commit 中修改了 package.json 中的 version (且该 version 在 registry 中没有发布过)的包(即 lerna publish from-package
)
发布在上一次提交中更新了的 unversioned 的测试版本的包(以及依赖了的包)
lerna publish
本身提供了不少的 options,例如支持发布测试版本的包即 (lerna version --canary
)。
在上文 lerna version 源码解析中,我们按照 configureProperties -> initialize -> execute
的顺序讲解了 lerna version 的执行顺序,其实在 lerna 中,几乎所有子命令源码的执行顺序都是按照这样一个结构在进行,lerna 本身作为一个 monorepo,主要是使用 core 核心中的执行机制来去分发命令给各个子项目去执行,因此套路都是一样的。
在开始阅读之前,我先提供一个整体的思维导图,可以让读者在开始阅读前有个大致的结构,也便于在阅读过程可以借此来进行回顾:
设置属性(configureProperties)
相比较于 lerna version,lerna publish
的这一步就简单许多,大致就是根据 cli 的 options 对一些参数进行了初始化:
configureProperties() {
const {
exact,
gitHead,
gitReset,
tagVersionPrefix = "v",
verifyAccess,
} = this.options;
if (this.requiresGit && gitHead) {
throw new ValidationError("EGITHEAD", "--git-head is only allowed with 'from-package' positional");
this.savePrefix = exact ? "" : "^";
this.tagPrefix = tagVersionPrefix;
this.gitReset = gitReset !== false;
this.verifyAccess = verifyAccess !== false;
this.npmSession = crypto.randomBytes(8).toString("hex");
通过注释就可以比较清晰的看到一些 options 以及相关参数的初始化,这里就不详细介绍。
初始化(initialize)
下面直接进来初始化的流程中来,因为涉及到发包相关的流程,这一步的前面过程涉及到的就是一些关于 npm 相关的 config 初始化,之后再根据不同的发包情况去进行对应的事件注册,这一步的事件注册以及执行方式都和 lerna version
源码解析时比较类似,主要过程可以分为三个步骤:
初始化 npm config 参数
根据不同的发包情况执行不同的方法
处理上一步返回的结果
这里不同的发包情况指的即是在文章开头介绍的 lerna publish 的几种执行方式,这里大致梳理一下以下的步骤:
initialize() {
if (this.options.skipNpm) {
this.logger.warn("deprecated", "Instead of --skip-npm, call `lerna version` directly");
return versionCommand(this.argv).then(() => false);
this.logger.verbose("session", this.npmSession);
this.logger.verbose("user-agent", this.userAgent);
this.conf = npmConf({
lernaCommand: "publish",
_auth: this.options.legacyAuth,
npmSession: this.npmSession,
npmVersion: this.userAgent,
otp: this.options.otp,
registry: this.options.registry,
"ignore-prepublish": this.options.ignorePrepublish,
"ignore-scripts": this.options.ignoreScripts,
const distTag = this.getDistTag();
if (distTag) {
this.conf.set("tag", distTag.trim(), "cli");
this.runPackageLifecycle = createRunner(this.options);
this.runRootLifecycle = /^(pre|post)?publish$/.test(process.env.npm_lifecycle_event)
? stage => {
this.logger.warn("lifecycle", "Skipping root %j because it has already been called", stage);
: stage => this.runPackageLifecycle(this.project.manifest, stage);
let chain = Promise.resolve();
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 {
chain = chain.then(() => versionCommand(this.argv));
return chain.then(result => {
if (!result) {
return false;
if (!result.updates.length) {
this.logger.success("No changed packages to publish");
return false;
this.updates = result.updates.filter(node => !node.pkg.private);
this.updatesVersions = new Map(result.updatesVersions);
this.packagesToPublish = this.updates.map(node => node.pkg);
if (this.options.contents) {
for (const pkg of this.packagesToPublish) {
pkg.contents = this.options.contents;
if (result.needsConfirmation) {
return this.confirmPublish();
return true;
initialize
前面有介绍主要分为三个步骤来执行,因此 1、3 两个步骤根据注释来理解过程还是比较清晰的,这里主要介绍一下第二步即 根据不同的发包情况来执行不用的方法,具体代码:
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 {
chain = chain.then(() => versionCommand(this.argv));
首先根据上面代码中以及文章开头介绍,可以很清晰的知道具体分为这几种情况:
from-git
即根据 git commit
上的 annotaed tag
进行发包
from-package
即根据 lerna 下的 package 里面的 pkg.json 的 version 变动来发包
--canary
发测试版本的包
剩下不带参数的情况就直接走一个 bump version(即执行 lerna version)
下面从这几种情况做个介绍:
from-git
这一步的执行入口函数是 detectFromGit
,我们直接看这个函数的执行过程:
detectFromGit() {
const matchingPattern = this.project.isIndependent() ? "*@*" : `${this.tagPrefix}*.*.*`
;
let chain = Promise.resolve();
chain = chain.then(() => this.verifyWorkingTreeClean());
chain = chain.then(() => getCurrentTags(this.execOpts, matchingPattern));
chain = chain.then(taggedPackageNames => {
if (!taggedPackageNames.length) {
this.logger.notice("from-git", "No tagged release found");
return [];
if (this.project.isIndependent()) {
return taggedPackageNames.map(name => this.packageGraph.get(name));
return getTaggedPackages(this.packageGraph, this.project.rootPath, this.execOpts);
chain = chain.then(updates => updates.filter(node => !node.pkg.private));
return chain.then(updates => {
const updatesVersions = updates.map(node => [node.name, node.version]);
return {
updates,
updatesVersions,
needsConfirmation: true,
可以看到这一步函数的执行过程还是比较简单明了的,在上面注释中根据不同的方法执行过程分为了 5 个步骤。主要就是根据当前 commit 拿到 tags 里面的 packages 然后返回这些 packages 以及其版本信息。
from-package
这一步执行的入口函数是 detectFromPackage
,直接看执行过程:
detectFromPackage() {
let chain = Promise.resolve();
chain = chain.then(() => this.verifyWorkingTreeClean());
chain = chain.then(() => getUnpublishedPackages(this.packageGraph, this.conf.snapshot));
chain = chain.then(unpublished => {
if (!unpublished.length) {
this.logger.notice("from-package", "No unpublished release found");
return unpublished;
return chain.then(updates => {
const updatesVersions = updates.map(node => [node.name, node.version]);
return {
updates,
updatesVersions,
needsConfirmation: true,
这一步主要是在 getUnpublishedPackages
这一步筛选出需要更新的 packages,这里 lerna 作者使用了自己封装的 pacote 库来去做一些关于版本的比对,从而得到需要更新的 packages,这里有想了解的可以自行去阅读一下,不做过多赘述。
--canary
这一步执行的入口函数是 detectCanaryVersions
,直接看执行过程:
detectCanaryVersions() {
const { cwd } = this.execOpts;
const {
bump = "prepatch",
preid = "alpha",
ignoreChanges,
forcePublish,
includeMergedTags,
} = this.options;
const release = bump.startsWith("pre") ? bump.replace("release", "patch") : `pre${bump}`;
let chain = Promise.resolve();
chain = chain.then(() => this.verifyWorkingTreeClean());
chain = chain.then(() =>
collectUpdates(this.packageGraph.rawPackageList, this.packageGraph, this.execOpts, {
bump: "prerelease",
canary: true,
ignoreChanges,
forcePublish,
includeMergedTags,
}).filter(node => !node.pkg.private)
const makeVersion = fallback => ({ lastVersion = fallback, refCount, sha }) => {
const nextVersion = semver.inc(lastVersion.replace(this.tagPrefix, ""), release.replace("pre", ""));
return `${nextVersion}-${preid}.${Math.max(0, refCount - 1)}+${sha}`;
if (this.project.isIndependent()) {
chain = chain.then(updates =>
pMap(updates, node =>
describeRef(
match: `${node.name}@*`,
includeMergedTags
.then(makeVersion(node.version))
.then(version => [node.name, version])
).then(updatesVersions => ({
updates,
updatesVersions,
} else {
chain = chain.then(updates =>
describeRef(
match: `${this.tagPrefix}*.*.*`,
includeMergedTags
.then(makeVersion(this.project.version))
.then(version => updates.map(node => [node.name, version]))
.then(updatesVersions => ({
updates,
updatesVersions,
return chain.then(({ updates, updatesVersions }) => ({
updates,
updatesVersions,
needsConfirmation: true,
相比较于上面两步, --canary
的处理过程或许看上去要复杂一些,其实不然,根据上面代码注释中的内容可以比较清晰的看到整个执行流程,不过多了几种特殊情况需要去做一些判断,其中比较复杂的第三步,是需要通过 tag 得到一些相关的信息,需要更新的包,然后针对这些包现有的版本去做一些计算,可以参考上面的 makeVersion
方法,这里就根据 lerna 的 mode 分为了两种情况。
其中这里第二步还用到了在 lerna version 中收集变更的包的方法:collectUpdates
。具体的执行机制可以参考我的上一篇关于 lerna version 的文章。
bump version
如果不带参数的话,那么这一步就会直接执行一个 lerna version 的过程,一般 lerna publish 的预期行为是这样:
chain = chain.then(() => versionCommand(this.argv));
lerna version 的具体执行机制可以参考我的上一篇文章。
看完这几种情况之后再回到开头,再回顾一下 initialize 这一步最后对结果的一个处理过程,大致 initialize 的一个流程就这样结束了。
最后总结一下 lerna publish 的初始化过程,主要就是根据不同的发包情况,然后计算出需要发布的包的信息,例如包名称和更新版本。用于下一步发包的 execute 做准备。
执行(execute)
lerna publish
的最后一步即发包的过程就是在这里完成,代码结构为:
execute() {
let chain = Promise.resolve();
chain = chain.then(() => this.prepareRegistryActions());
chain = chain.then(() => this.prepareLicenseActions());
if (this.options.canary) {
chain = chain.then(() => this.updateCanaryVersions());
chain = chain.then(() => this.resolveLocalDependencyLinks());
chain = chain.then(() => this.annotateGitHead());
chain = chain.then(() => this.serializeChanges());
chain = chain.then(() => this.packUpdated());
chain = chain.then(() => this.publishPacked());
if (this.gitReset) {
chain = chain.then(() => this.resetChanges());
return chain.then(() => {
const count = this.packagesToPublish.length;
const
message = this.packagesToPublish.map(pkg => ` - ${pkg.name}@${pkg.version}`);
output("Successfully published:");
output(message.join(os.EOL));
this.logger.success("published", "%d %s", count, count === 1 ? "package" : "packages");
execute
是 lerna publish
的主要部分了,这一步的相对而言信息量比较巨大,我接下来会将上面的步骤拆一拆,一步一步来讲解 execute
这一步是怎么完成 lerna 发包的整个过程的。
首先可以看到上面代码中,我通过注释将这个步骤分成了六步:
1. 验证 npm && 项目license
首先上面可以看到,这一步分为两个方法,一步是做 npm 相关的验证:prepareRegistryActions
prepareRegistryActions() {
let chain = Promise.resolve();
if (this.conf.get("registry") !== "https://registry.npmjs.org/") {
return chain;
if (this.verifyAccess) {
chain = chain.then(() => getNpmUsername(this.conf.snapshot));
chain = chain.then(username => {
if (username) {
return verifyNpmPackageAccess(this.packagesToPublish, username, this.conf.snapshot);
chain = chain.then(() => getTwoFactorAuthRequired(this.conf.snapshot));
chain = chain.then(isRequired => {
this.twoFactorAuthRequired = isRequired;
return chain;
prepareRegistryActions
执行时会先去校验 registry,如果是第三方的 registry,会停止校验,用户在发包设置了 no-verify-access
就不进行后面校验,默认会校验。
校验过程是首先通过 getNpmUsername
去拿到用户的 username,这里是通过 npm 提供的相关接口来获取,具体流程可以自行参考。拿到 username 之后根据 username 以及本次 publish 中需要发布的包的信息去做一个鉴权,判断用户是否用该包的读写发包权限,没有就会抛错,最后一步是个 2fa 的验证,一般 npm 包都不会开启,主要是为了安全作用做二次验证使用,这里不做具体讲解。
下面在看 license 的校验过程,方法是 prepareLicenseActions
:
prepareLicenseActions() {
return Promise.resolve()
.then(() => getPackagesWithoutLicense(this.project, this.packagesToPublish))
.then(packagesWithoutLicense => {
if (packagesWithoutLicense.length && !this.project.licensePath) {
this.packagesToBeLicensed = [];
const names = packagesWithoutLicense.map(pkg => pkg.name);
const noun = names.length > 1 ? "Packages" : "Package";
const verb = names.length > 1 ? "are" : "is";
const list =
names.length > 1
? `${names.slice(0, -1).join(", ")}${names.length > 2 ? "," : ""} and ${
names[names.length - 1] /* oxford commas _are_ that important */
: names[0];
this.logger.warn(
"ENOLICENSE",
"%s %s %s missing a license.\n%s\n%s",
noun,
list,
verb,
"One way to fix this is to add a LICENSE.md file to the root of this repository.",
"See https://choosealicense.com for additional guidance."
} else {
this.packagesToBeLicensed = packagesWithoutLicense;
这一步并不会对主要流程有什么影响,主要就是找目前待发布的包中没有 license 的,然后给个 warnning 提示,这里找的方式使用过 lerna 自己构造的 project graph 去筛待发布包中不存在 liecense 文件的路径,想了解具体过程参考 getPackagesWithoutLicense
。
2. 更新本地依赖版本 && 待发布包 gitHead
可以你会对更新本地依赖版本这一步可能会有些迷惑,这里举个例子来解释一下,在 lerna 中,如果 workspaces 之前存在依赖的话,在这次发包中,例如 A 这个包依赖了 B,B 在这次发包中版本升级了,那么这里 A 里面依赖的 B 也要更新到对应的版本。
来看一下这一步:
resolveLocalDependencyLinks() {
const updatesWithLocalLinks = this.updates.filter(node =>
Array.from(node.localDependencies.values()).some(resolved => resolved.type === "directory")
return pMap(updatesWithLocalLinks, node => {
for (const [depName, resolved] of node.localDependencies) {
const depVersion = this.updatesVersions.get(depName) || this.packageGraph.get(depName).pkg.version;
node.pkg.updateLocalDependency(resolved, depVersion, this.savePrefix);
这里涉及到的一些操作方法,都是来自于 lerna 构建的 project graph,这部分可以去参考一下 lerna core 中源码。
这里的 gitHead 是一个 hash 值,用户可以通过 --git-head 来自行指定,如果不指定的话,lerna 这里会默认帮你取当前 commit 的 hash 值,即通过 git rev-parse HEAD
来获取,一般 gitHead 结合 from-package
来使用,先看看代码:
annotateGitHead() {
try {
const gitHead = this.options.gitHead || getCurrentSHA(this.execOpts);
for (const pkg of this.packagesToPublish) {
pkg.set("gitHead", gitHead);
} catch (err) {
在使用 from-package
的方式进行发包的时候,会把这个 githead 字段写在 package.json
里面。
3. 更新写入本地
这一步就是将第二步的一些更新直接写到 lerna 中对应项目里面去,即写到磁盘里面,主要的方法为:
serializeChanges() {
return pMap(this.packagesToPublish, pkg => pkg.serialize());
这个 pkg.serialize()
方法,是可以在 lerna 的 core 中找到的,主要作用就是将相关的更新写入本地磁盘:
serialize() {
return writePkg(this.manifestLocation, this[PKG]).then(() => this);
4. package pack
在讲解之前,我们得先知道 npm pack
这个操作是干什么的,它会打包当前的文件夹内容打包成一个 tar 包,我们在执行 npm publish 的时候会经常看到这个操作:
不过 npm publish 帮我们封装了这个过程,lerna publish 中也会有这个过程,这已经是发包前的最后一个操作了,具体可参考代码:
packUpdated() {
let chain = Promise.resolve();
const opts = this.conf.snapshot;
const mapper = pPipe(
pkg =>
pulseTillDone(packDirectory(pkg, pkg.location, opts)).then(packed => {
pkg.packed = packed;
return pkg.refresh();
].filter(Boolean)
chain = chain.then(() => this.topoMapPackages(mapper));
return pFinally(chain, () => tracker.finish());
这一步首先可以参考 topoMapPackages
这个方法,他会按照拓扑顺序去对需要更新的包进行 pack,这里 publish 因为涉及到包之间的一些依赖关系,因此只能按照拓扑的顺序去执行,this.packagesToPublish
里面存的是待发布的包:
topoMapPackages(mapper) {
return runTopologically(this.packagesToPublish, mapper, {
concurrency: this.concurrency,
rejectCycles: this.options.rejectCycles,
graphType: this.options.graphType === "all" ? "allDependencies" : "dependencies",
因此这里会按照拓扑顺序去对要发布的包进行打包成 tar 包的操作,具体执行方法是 packDirectory
这个方法,这个方法我只贴一下打包的那一段逻辑,还有一些其他的预处理逻辑做了一下删除:
const tar = require("tar");
const packlist = require("npm-packlist");
function packDirectory(_pkg, dir, _opts) {
let chain = Promise.resolve();
chain = chain.then(() => packlist({ path: pkg.contents }));
chain = chain.then(files =>
tar.create(
cwd: pkg.contents,
prefix: "package/",
portable: true,
mtime: new Date("1985-10-26T08:15:00.000Z"),
gzip: true,
files.map(f => `./${f}`)
chain = chain.then(stream => tempWrite(stream, getTarballName(pkg)));
chain = chain.then(tarFilePath =>
getPacked(pkg, tarFilePath).then(packed =>
Promise.resolve()
.then(() => runLifecycle(pkg, "postpack", opts))
.then(() => packed)
return chain;
5. Package publish
在上一步完成了待发布包的打包操作之后,这一步就是 lerna publish 整个流程的最后一步了!
这一步会将上一次打包的内容直接发布出去,先来看一下代码:
publishPacked() {
let chain = Promise.resolve();
if (this.twoFactorAuthRequired) {
chain = chain.then(() => this.requestOneTimePassword());
const opts = Object.assign(this.conf.snapshot, {
tag: this.options.tempTag ? "lerna-temp" : this.conf.get("tag"),
const mapper = pPipe(
pkg => {
const preDistTag = this.getPreDistTag(pkg);
const tag = !this.options.tempTag && preDistTag ? preDistTag : opts.tag;
const pkgOpts = Object.assign({}, opts, { tag });
return pulseTillDone(npmPublish(pkg, pkg.packed.tarFilePath, pkgOpts, this.otpCache)).then(() => {
return pkg;
].filter(Boolean)
chain = chain.then(() => this.topoMapPackages(mapper));
return pFinally(chain, () => tracker.finish());
上一步讲了 topoMapPackages
这个方法,这里同样的,它会按照拓扑顺序去发布待发布的 pkg。
在 npmPublish
这个方法中,会将前面打包的 pkg 的 tar 包 publish 到 npm 上面去,这里用的是 lerna 作者自己的一个包,感兴趣的可以去 npm 上搜一下:@evocateur/libnpmpublish
这个包可以不用担心 tarball 打包自于哪个 pkg,只要你有个 tarball 它会帮你直接上传到 npm 上面去来完成一次发布,具体的内容可以在 npm 中找到。
这里因为引入了一些外部包加上这里有太多的边界条件处理,这里就不具体去看 npmPublish
这个方法了,贴上发布的那部分代码,可以参考一下:
const { publish } = require("@evocateur/libnpmpublish");
return otplease(innerOpts => publish(manifest, tarData, innerOpts), opts, otpCache).catch(err => {
throw err;
那么再走到这一步结束之后,基本上整个 lerna 的发包流程都走完了。
后续的一些收尾工作的处理,可以再拉回 执行(execute) 这一节开头的代码分析那里。
本文从源码角度剖析了一下 lerna publish 的执行机制,对于一些边界的 corner case 有些删减,按照主线讲解了 lerna publish 是怎么完成 lerna monorepo 中的整个发包流程操作的。希望本系列的文章能对你有所帮助。