Monorepo 自动 release 的思考

简介

最近在 Monorepo 模式下开发开源软件 plus-pro-components 时遇到自动 release 的问题,调研了 Changesets,实际使用发现它发包灵活度太低而且过程繁琐,果断弃用。调研了 rushjs,发现 rush 很规范,但是对于团队来说,上手成本太高,也弃用。于是查看 vue 源码,发现它是自己写的,高度契合项目,对我来说这种解决方案非常完美,于是自己写了一个自动 release 的工具。

实现的效果

  1. 自动查询子包
  2. 按需更新子包(符合 semver 规范 )的版本
  3. 自动生成 changelog
  4. 自动 commitLint (符合 angular 提交规范 )
  5. 自动代码 lint
  6. 自动打 tag
  7. 自动提交代码

核心源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
import path from 'path'
import fs from 'fs'
import semver from 'semver'
import consola from 'consola'
import execa from 'execa'
import { checkbox, select, input } from '@inquirer/prompts'
import { findWorkspacePackages } from '@pnpm/find-workspace-packages'
import { projRoot, pcPackage, projPackage } from '../build/paths'
import { PKG_NAME } from '../build/utils'

type SemverRow = {
release: semver.ReleaseType
optionsOrLoose?: boolean | semver.Options | string
identifier?: string
}

// 打印
const echo = (msg: string) => consola.success(msg)

// 运行脚本
const run = (bin: string, args: string[], opts = {}) =>
execa(bin, args, { stdio: 'inherit', ...opts })

// 版本列表
const versionIncrements: SemverRow[] = [
{
release: 'patch'
},
{
release: 'minor'
},
{
release: 'major'
},
{
release: 'prepatch',
optionsOrLoose: 'rc',
identifier: '1'
},
{
release: 'preminor',
optionsOrLoose: 'rc',
identifier: '1'
},
{
release: 'premajor',
optionsOrLoose: 'rc',
identifier: '1'
},
{
release: 'prerelease',
optionsOrLoose: 'alpha',
identifier: '1'
},
{
release: 'prerelease',
optionsOrLoose: 'beta',
identifier: '1'
}
]

// 获取工作空间包
const getWorkspaceList = async (dir = projRoot) => {
const pkgs = await findWorkspacePackages(projRoot)
return pkgs
.filter(pkg => pkg.dir.startsWith(dir))
.filter(pkg => pkg.manifest.private !== true && pkg.manifest.name)
}

/**
* 更新版本号
* @param {string} version
*/
const updatePackage = (version: string, pkgPath: string) => {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
pkg.version = version
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
}

// 获取版本
const getVersion = async (currentVersion: string) => {
// 发布版本
let version: string | null
const selectChoices = versionIncrements
.map(item => {
const value = semver.inc(
currentVersion,
item.release,
item.optionsOrLoose as any,
item.identifier
)
const name = `${item.release} (${value}})`

return {
name,
value
}
})
.concat({ name: 'custom', value: 'custom' })

version = await select({
message: 'Select release type',
choices: selectChoices
})

// 自定义版本
if (version === 'custom') {
version = await input({ message: 'Enter custom version' })
// 校验版本
if (!semver.valid(version)) {
throw new Error(`Illegal version: ${version}`)
}
}

return version
}

// 提交
async function commit(version?: string) {
try {
// 生成changelog
if (version) {
await run('npm', ['run', '--name', 'changelog'])
}

await run('git', ['add', '-A'])

// 打tag
if (version) {
await run('git', ['tag', '-a', version, '-m', `v${version}`])
}

// 规范化提交
await run('npm', ['run', '--name', 'gitcz'])
await run('git', ['pull'])

// push tag
if (version) {
await run('git', ['push', '--tags'])
}
await run('git', ['push'])
echo(`\ncommit success ${version}`)
} catch (error: any) {
throw new Error(error)
}
}

const main = async () => {
const workspaceList = await getWorkspaceList()
const workspaceNames = workspaceList.map(
item => item.manifest.name
) as string[]
const workspaceMaps = workspaceList.map(item => ({
dir: item.dir,
name: item.manifest.name,
version: item.manifest.version,
pkg: path.resolve(item.dir, 'package.json')
}))

// 选择需要更新的包
let selectPackages: string[] = []
const checkboxChoices = workspaceNames.map(item => ({
name: item,
value: item
}))
const packages = await checkbox({
message: 'Which packages would you like to include?',
choices: [{ name: 'all', value: 'all' }, ...checkboxChoices]
})

if (!packages.length) {
throw new Error('Please select one or more packages!')
}

if (packages.includes('all')) {
selectPackages = workspaceNames
} else {
selectPackages = [...packages]
}

// 更新版本号
for (let index = 0; index < selectPackages.length; index++) {
const name = selectPackages[index]
const packageInfo = workspaceMaps.find(i => i.name === name)
const version = await getVersion(packageInfo?.version as string)
updatePackage(version as string, packageInfo?.pkg as string)
consola.success(`Successfully updated version ${name}!`)
}

if (selectPackages.includes(PKG_NAME)) {
// 主包更新
const mainPkg = JSON.parse(fs.readFileSync(pcPackage, 'utf-8'))
updatePackage(mainPkg.version as string, projPackage)
commit(mainPkg.version)
} else {
commit()
}
}

main()
.then(() => {
console.log('success')
})
.catch(err => {
console.log(err)
})

运行效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ yarn release
yarn run v1.22.19
$ tsx scripts/release/index.ts
? Which packages would you like to include? (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed)
>( ) all
( ) @plus-pro-components/echarts
( ) @plus-pro-components/eslint-config
( ) plus-pro-components
( ) @plus-pro-components/utils

? Which packages would you like to include? plus-pro-components
? Select release type (Use arrow keys)
> patch (0.0.1})
minor (0.1.0})
major (1.0.0})
prepatch (0.0.2-rc.1})
preminor (0.1.0-rc.1})
premajor (1.0.0-rc.1})
prerelease (0.0.1-alpha.4})
(Move up and down to reveal more choices)
...

源码地址

源码地址