0%

团队敏捷实践 —— 使用 semantic-release 实现自动化发布

前言

在之前的分享中,我们团队已经成功运用了 Gitlab CI,并且已经结构化提交 git commit 记录,我们希望更进一步,自动管理发布版本,自动生成更新日志,自动发布 NPM 包,因此我们引入了 semantic-release 进一步自动化管理我们的发布流程。

semantic-release 概述

有关semantic-release的详细介绍可以阅读官方文档,这里只做一些概述性的总结。和 standard-version 相比,semantic-release 更适合在 CI 环境中运行,它自带支持各种 git server 的认证支持,如 Github,Gitlab,Bitbucket 等等,此外,还支持插件,以便完成其他后续的流程步骤,比如自动生成 git tag 和 release note 之后再 push 回中央仓库,自动发布 npm 包等等。

semantic-release 会根据规范化的 commit 信息生成发布日志,默认使用 angular 规则,其他规则可以配置插件完成。

semantic-release 大致的工作流如下:

  • 提交到特定的分支触发 release 流程
  • 验证 commit 信息,生成 release note,打 git tag
  • 其他后续流程,如生成 CHANGELOG.md,npm publish 等等(通过插件完成)

由 CI 自动执行之后的效果就像这样,在 Git tag 页面可以看到 tag 信息,同时包含更新记录:
86f880f284d3072ec2179cbc46c32396

如果启用了@semantic-release/git 插件,还会将生成的 CHANGELOG.md 反向 push 回中央仓库:
6cfc655f89c58bc6a2faa5e3aee6eea1

commit history 的实际效果如下
c15015e5517056c98eca17829d479a12

实践

在项目工程中添加 release.config.js 配置如下:

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
parserOpts = {
mergePattern: /^Merge pull request #(\d+) from (.*)$/,
mergeCorrespondence: ['id', 'source'],
}
// Copied from https://github.com/conventional-changelog/conventional-changelog/blob/master/packages/conventional-changelog-angular/writer-opts.js#L27
// and modified to support adding all commit types to the release notes
customTransform = (commit, context) => {
const issues = []
commit.notes.forEach((note) => {
note.title = `BREAKING CHANGES`
})
if (commit.type === `feat`) {
commit.type = `✨ Features`
} else if (commit.type === `fix`) {
commit.type = `🐞 Bug Fixes`
} else if (commit.type === `perf`) {
commit.type = `🎈 Performance Improvements`
} else if (commit.type === `revert`) {
commit.type = `Reverts`
} else if (commit.type === `docs`) {
commit.type = `📃 Documentation`
} else if (commit.type === `style`) {
commit.type = `🌈 Styles`
} else if (commit.type === `refactor`) {
commit.type = `🦄 Code Refactoring`
} else if (commit.type === `test`) {
commit.type = `🧪 Tests`
} else if (commit.type === `build`) {
commit.type = `🔧 Build System`
} else if (commit.type === `ci`) {
commit.type = `🐎 Continuous Integration`
} else {
return
}
if (commit.scope === `*`) {
commit.scope = ``
}
if (typeof commit.hash === `string`) {
commit.shortHash = commit.hash.substring(0, 7)
}
if (typeof commit.subject === `string`) {
commit.subject = commit.subject.substring(2)
let url = context.repository
? `${context.host}/${context.owner}/${context.repository}`
: context.repoUrl
if (url) {
url = `${url}/issues/` // Issue URLs.
commit.subject = commit.subject.replace(/#([0-9]+)/g, (_, issue) => {
issues.push(issue)
return `[#${issue}](${url}${issue})`
})
}
if (context.host) {
// User URLs.
commit.subject = commit.subject.replace(
/\B@([a-z0-9](?:-?[a-z0-9/]){0,38})/g,
(_, username) => {
if (username.includes('/')) {
return `@${username}`
}
return `[@${username}](${context.host}/${username})`
}
)
}
commit.subject = `${commit.subject} (by @${commit.committer.name})`
} // remove references that already appear in the subject
commit.references = commit.references.filter((reference) => {
if (issues.indexOf(reference.issue) === -1) {
return true
}
return false
})
return commit
}
module.exports = {
branches: 'master',
parserOpts,
writerOpts: { transform: customTransform },
plugins: [
[
'@semantic-release/commit-analyzer',
{
preset: 'angular',
releaseRules: [
{ type: 'docs', scope: 'README', release: 'patch' },
{ type: 'refactor', release: 'patch' },
{ type: 'style', release: 'patch' },
{ type: 'test', release: 'patch' },
{ type: 'build', release: 'patch' },
{ type: 'ci', release: 'patch' },
],
},
],
'@semantic-release/release-notes-generator',
['@semantic-release/changelog', { changelogFile: 'CHANGELOG.md' }],
'@semantic-release/npm',
[
'@semantic-release/git',
{
assets: ['package.json', 'CHANGELOG.md'],
},
],
[
'@semantic-release/gitlab',
{
gitlabUrl: 'http://git.example.com',
assets: [],
},
],
],
}

完成 .gitlab-ci.yml 配置如下(仅部分关键的配置片段):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
stages:
    - lint
    - release
commitlint:
    stage: lint
    rules:
        - if: '$CI_COMMIT_TITLE =~ /^chore\(release\)/'
          when: never
        - if: '$CI_COMMIT_TITLE =~ /^Merge branch/'
          when: never
        - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # 一般当分支master有 push 或 merge 时才会执行该工作
    script:
        - sh bin/commitlint-gitlab-ci.sh
release:
    stage: release
    rules:
        - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    script:
        - semantic-release

commitlint 脚本请参考commitlint-gitlab-ci.sh

小结

至此,我们完成了通过 CI 自动管理版本号和发布日志的需求,大大节省了人力,同时,还留下了发布痕迹,方便追溯历史版本。
另外,需要注意的是上述的配置并不会修改源码部分的版本号配置内容(如 build.gradle 或 package.json 等),如果需要自动管理这些地方的版本,与 git tag 版本保持一致,可以引入@semantic-release/exec 插件,自己写脚本,通过脚本自动化修改这些地方的版本号。
还需要注意的是 semantic-release 默认产生的 commit 记录为了避免不必要的 CI 流程,会在 commit 记录加上[skip ci](见上面的截图)来跳过 CI,如果你的流水线需要由 git tag 触发,可以配置@semantic-release/git 插件,自定义 commit 记录,去掉[skip ci]。