7. GitHub Actions - 在pull request中执行eslint检测的工作流例子

发布 : 2023-12-28 分类 : 工程化

原文链接:https://github.com/taoliujun/blog/issues/36

一个在pull request发起的时候执行eslint检测的workflow,点此查看完整代码,它实现的功能如下:

  • 在pull request创建、更新的时候执行。
  • 先回复一个评论,告诉用户正在运行。
  • 初始化仓库,并安装依赖,产生依赖缓存。
  • 运行eslint增量检查。
  • 运行typescript检查。
  • 运行jest检查。
  • 更新之前的评论,回复检查的结果。

运行截图:

Alt text

为避免歧义,涉及到github action的术语都是英文的。术语介绍如下:

  • workflow,工作流,可以理解为yml文件。
  • jobs,工作,一个workflow可以包含多个job,并行执行。
  • steps,作业,一个job可以包含多个step,串行执行。
  • action,操作,作业中具体的执行。

步骤

初始化workflow

在项目中新建文件.github/workflows/check-pull-request.yml,内容如下:

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
name: test check pull request
run-name: 'check pull request #${{ github.event.pull_request.number }}'
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
replyChecking:
runs-on: ubuntu-latest
steps:
- run: echo 'replyChecking'

init:
runs-on: ubuntu-latest
steps:
- run: echo 'init'

eslint:
runs-on: ubuntu-latest
needs: [init]
steps:
- run: echo 'eslint'

typescript:
runs-on: ubuntu-latest
needs: [init]
steps:
- run: echo 'typescript'

unitTest:
runs-on: ubuntu-latest
needs: [init]
steps:
- run: echo 'unitTest'

replyResult:
runs-on: ubuntu-latest
needs: [replyChecking, eslint, typescript, unitTest]
steps:
- run: echo 'replyResult'

name和run-name

给workflow命名为check pull request,它会出现在Actions页面的左侧菜单中。运行实例名为check pull request #44,出现在右侧的运行列表中。如图:

run-name中的${{ github.event.pull_request.number }}是workflow的上下文,这里读取了上下文中的pr编号。

on

on指定了workflow的触发条件,这里配置了在pr创建、同步、重新打开的时候,触发该workflow。

jobs

按照设想,需要定义几个job,分别是:

  • replyChecking:回复用户正在检查中
  • init:初始化仓库,缓存依赖项
  • eslint:运行eslint检查
  • typescript:运行typescript检查
  • unitTest:运行单元测试
  • replyResult:回复用户检查结果

jobs是并行运行的,聪明如你肯定发现了,eslint、typescript、unitTest这三个job会涉及到安装npm依赖,所以它们最好在init后执行,确保依赖已经缓存了。

其次,replyResult肯定要拿到eslint等job的结果才能执行,所以使用了needs管理它们的执行依赖关系。

runs-on

每个job都运行在独立的容器中,github官方提供了windows、macos、linux多种容器,这里使用了ubuntu容器。

测试

发起一个pr,看到Actions页面出现了新的运行实例,点击进去,可以看到各个job的运行情况和依赖关系:

replyChecking

在进行eslint检测之前,先在pr里回复checking,并且带上拽酷炫的话。将replyChecking改成如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
replyChecking:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{github.head_ref}}
- name: Get date time
id: getDateTime
run: echo "result=$(TZ=Asia/Shanghai date)" >> "$GITHUB_OUTPUT"
- name: Create or update a comment
uses: ./.github/actions/unique-comment
with:
uniqueIdentifier: ${{ github.workflow }}
body: |
**Checking...**

---

Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.

steps每一步里nameid是可选的,name在Actions详情页面里会显示,更直观的看到step的名称,推荐写上。

Checkout

uses表示使用一个action,名为actions/checkout@v4,它用来拉取仓库。

同其他编程语言一样,重复的action可以封装起来。action市场提供了很多。

with属性指定了该action的输入参数,每个action的参数不尽相同。

ref参数表示要拉取的分支,${{github.head_ref}}也是一个上下文,表示当前pr的源分支。

Get Date time

这step还写了id,表示该step在该job中的唯一标识,为什么要写呢?是为了下一步step能根据id读取到它的output

output是workflow中非常重要的概念,它用于在step之间、job之间分享简单的数据。

run就是在容器中跑一个命令,这里跑了一个unix bash命令,将当前时间写入到$GITHUB_OUTPUT中,键名为result

$GITHUB_OUTPUT是workflow注入到容器中的一个路径,用于存放output。

Create or update a comment

uses使用了本地的action,这个action用于创建或更新一个唯一回复,下一节说。

有时候,官方或市场的action并不能满足你的需要,就得自己写一个了。

同理,该action也有with属性,uniqueIdentifier是回复评论的唯一标识,body是回复的内容,内容使用了markdown语法,里面还涉及到上下文不一一细讲了。只说${{steps.getDateTime.outputs.result}}这个上下文表示获取getDateTime这个step中,键名为result的值。

如果你不需要在内容里插入时间,那么上面的Get Date time就可以省略了。

测试

因为我已经有完整的代码了,所以运行后,pr中会有一个回复,如图:

./.github/actions/unique-comment

这是一个封装的javascript action,用于对issue创建、更新唯一评论。

目录结构

创建目录./.github/actions/unique-comment,最终目录结构如下:

1
2
3
4
5
6
7
8
9
10
.
├── action.yml
├── config
│   └── webpack.config.js
├── dist
│   ├── index.js
│   └── index.js.LICENSE.txt
├── package.json
└── src
└── index.js

action.yml

这是action的配置文件,必须存在,内容如下:

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
name: unique-comment
description: create or update a unique comment

runs:
using: 'node20'
main: './dist/index.js'

inputs:
token:
description: 'GitHub token'
required: false
default: ${{ github.token }}
owner:
description: 'Repository owner'
required: false
default: ${{ github.event.repository.owner.login }}
repo:
description: 'Repository name'
required: false
default: ${{ github.event.repository.name }}
issue_number:
description: 'Issue number'
required: false
default: ${{ github.event.number }}
body:
description: 'Comment body'
required: false
uniqueIdentifier:
description: 'Unique identifier for comment'
required: false
default: 'unique-comment'

大部分属性不一一细讲了,都是简单的英文望文生义即可。

runs表示运行在node20环境下,入口文件为./dist/index.js

inputs表示接受的参数,也就是之前提到的with属性里要输入的参数。用required表示是否必须传入,default表示默认值。

src/index.js

为什么入口文件是dist/index.js,而不是src/index.js呢?因为要引用一些github官方提供的快捷操作github REST API的js包去操作issue评论(pull request也是一种issue),最终打包后的文件才能在工作流中稳妥的运行。所以,写好src/index.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
const core = require('@actions/core');
const github = require('@actions/github');

const main = async () => {
const token = core.getInput('token');
const owner = core.getInput('owner');
const repo = core.getInput('repo');
const issueNumber = core.getInput('issue_number');
const uniqueIdentifier = `[^uniqueIdentifier]: ${core.getInput('uniqueIdentifier')}`;
const body = `${core.getInput('body')}\n\n${uniqueIdentifier}`;

core.debug(`uniqueIdentifier is ${uniqueIdentifier}`);

const octokit = github.getOctokit(token);

const comments = await octokit.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
});

const botComment = comments.data.find((v) => v.body.includes(uniqueIdentifier));

if (botComment) {
core.info('update comment successfully.');
await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: botComment.id,
body,
});
} else {
core.info('create comment successfully.');
await octokit.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body,
});
}
};

try {
main();
} catch (err) {
core.setFailed(err.message);
}

@actions/core@actions/github是github官方提供的js包,前者可以方便的读取入参等,后者可以方便的操作github REST API。

main函数的代码就是原生javascript,不一一解释了,主要通过uniqueIdentifier来判断是否发布过评论,如果是,就更新评论,否则就创建评论。

markdown语法[^uniqueIdentifier]表示脚注,不会被渲染。

core.setFailed(err.message);表示抛出退出代码。

config/webpack.config.js

打包用的,配置简单可用即可:

1
2
3
4
5
6
7
8
9
module.exports = {
mode: 'production',
target: 'node20',
entry: './src/index.js',
output: {
filename: 'index.js',
clean: true,
},
};

package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"name": "unique-comment",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "webpack --config ./config/webpack.config.js"
},
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.0"
},
"devDependencies": {
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4"
}
}

没啥好说的,列出了依赖项。和一个打包脚本。

测试

修改了src/index.jsbuild,然后push到github仓库。

记得将dist目录也提交到github仓库。

init

现在,开始搞正经的了。

先初始化项目,这个job的目的仅仅是为了缓存pnpm依赖项,如果你的项目的依赖项不经常更新,可以省略这个job,后续也不要needs这个job。

将init改成如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
init:
runs-on: ubuntu-latest
steps:
- name: Init repo
uses: actions/checkout@v4
with:
ref: ${{github.head_ref}}

- name: Init pnpm
uses: pnpm/action-setup@v2
with:
version: 8

- name: Init node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- name: Install dependencies
run: pnpm install

相信经过对之前的job的了解,这里的配置就看起来很简单了。

Init pnpm

使用第三方action,安装pnpm@^8。

Init node

cache: 'pnpm'指定缓存机制,它内部是利用了workflow的cache机制。

Install dependencies

安装依赖项,触发缓存。

eslint

将eslint改成如下:

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
eslint:
runs-on: ubuntu-latest
needs: [init]
outputs:
result: ${{ steps.lint.outputs.result }}
steps:
- name: Init repo
uses: actions/checkout@v4
with:
ref: ${{github.head_ref}}
fetch-depth: 0

- name: Init pnpm
uses: pnpm/action-setup@v2
with:
version: 8

- name: Init node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- name: Install dependencies
run: pnpm install

- name: Run eslint
id: lint
uses: actions/github-script@v7
with:
result-encoding: string
script: |
let output = '';
let outerr = '';
let diffFiles = '';

await exec.exec(
`git diff --name-only origin/${{github.base_ref}}`,
[],
{
// silent: true,
// ignoreReturnCode: true,
listeners: {
stdout: (data) => {
diffFiles += data.toString();
},
},
}
);

const lintFiles = diffFiles.split(`\n`).filter((file) => {
return file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.tsx')
}).join(' ');

await exec.exec(
// "pnpm run lint --format stylish",
`pnpm eslint ${lintFiles}`,
[],
{
// silent: true,
ignoreReturnCode: true,
listeners: {
stdout: (data) => {
output += data.toString();
},
stderr: (data) => {
outerr += data.toString();
},
},
}
);

if (outerr) {
return `:x: Some command execution errors, non-eslint business errors.`;
}

const errorMatch = output.match(/(\d+) errors?/);
const warnMatch = output.match(/(\d+) warnings?/);

if (errorMatch && errorMatch?.[1] !== '0') {
return `:x: ${errorMatch?.[0]} ${warnMatch?.[0]}`;
}

return `:white_check_mark: ${errorMatch?.[0] || '0 error'} ${warnMatch?.[0] || '0 warning'}`;

needs

使用needs依赖init,可以使用到pnpm的缓存项,防止install太慢。

因为eslint、typescript、unitTest都需要pnpm install,所以一个前置的init去缓存pnpm依赖项,可以加快后续的install速度。

outputs

job里的outputs,可以在依赖它的其他job中访问到。这里使用${{ steps.lint.outputs.result }}去获取该job中lint这个step里的output里的result。

output有job和step两个维度,注意区分。

Run eslint

它uses了actions/github-script@v7,这是github官方提供的一个action,可以在with.script里写js代码去执行,同时它会注入一些变量到script中去,见它的官方文档

对于简单的js代码,可以使用这个action去完成,不用再去写一个js文件。

result-encoding是指定script返回的数据格式的,默认是json,这指定为string。

为什么script里return了string,还要指定为string呢?
因为return 'hello'在json encode后是'"hello"',而string encode后为'hello'

script里是原生的js代码了,里面的exec是该action注入的变量,用来执行shell命令。

这段js代码做了两个事情,一是git diff获取pr中改动的文件列表,二是eslint检查这些增量文件,最后返回处理的结果。

fetch-depth

Init repo这个step里设置了fetch-depth: 0,不然获取不到完整的git分支,具体看actions/checkout的解释,涉及到git的知识不展开细说了。

steps.lint.outputs.result

steps.lint.outputs.result为什么能拿到lint step里的output.result呢?因为actions/github-script这个action内部将script的返回值,设置到$GITHUB_OUTPUT里了,且键名为result

typescript

和eslint的配置大同小异,只是改了对检测结果的判断。

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
typescript:
runs-on: ubuntu-latest
needs: [init]
outputs:
result: ${{ steps.lint.outputs.result }}
steps:
- name: Init repo
uses: actions/checkout@v4
with:
ref: ${{github.head_ref}}

- name: Init pnpm
uses: pnpm/action-setup@v2
with:
version: 8

- name: Init node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- name: Install dependencies
run: pnpm install

- name: Run lint
id: lint
uses: actions/github-script@v7
with:
result-encoding: string
script: |
let output = '';
let outerr = '';

await exec.exec(
`pnpm run -r lint:ts`,
[],
{
// silent: true,
ignoreReturnCode: true,
listeners: {
stdout: (data) => {
output += data.toString();
},
stderr: (data) => {
outerr += data.toString();
},
},
}
);

if (outerr) {
return `:x: Some command execution errors, no business errors.`;
}

const errorMatch = output.match(/error TS/g);

if (errorMatch) {
return `:x: ${errorMatch?.length} errors`;
}

return `:white_check_mark: ${'0 error'}`;

unitTest

和eslint的配置大同小异,只是改了对检测结果的判断。唯一的区别是jest的检测结果是输出到stderr,见https://github.com/jestjs/jest/issues/5064。

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
unitTest:
runs-on: ubuntu-latest
needs: [init]
outputs:
result: ${{ steps.lint.outputs.result }}
steps:
- name: Init repo
uses: actions/checkout@v4
with:
ref: ${{github.head_ref}}

- name: Init pnpm
uses: pnpm/action-setup@v2
with:
version: 8

- name: Init node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- name: Install dependencies
run: pnpm install

- name: Run lint
id: lint
uses: actions/github-script@v7
with:
result-encoding: string
script: |
let output = '';
let outerr = '';

await exec.exec(
`pnpm run test`,
[],
{
// silent: true,
ignoreReturnCode: true,
listeners: {
stdout: (data) => {
output += data.toString();
},
stderr: (data) => {
outerr += data.toString();
},
},
}
);

// why use outerr? https://github.com/jestjs/jest/issues/5064

const failMatch = outerr.match(/Test Suites: \d+ failed/);

if (failMatch) {
return `:x: ${failMatch?.[0]}`;
}

const errorMatch = outerr.match(/Jest: "global" coverage threshold for lines \([0-9\.]+%\) not met: [0-9\.]+%/);

if (errorMatch) {
return `:x: ${errorMatch?.[0]}`;
}

return `:white_check_mark: passed`;

replyResult

最后,将几个检测的结果进行汇总,回复到pr里就行了。

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
replyResult:
runs-on: ubuntu-latest
needs: [replyChecking, eslint, typescript, unitTest]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{github.head_ref}}
- name: Get date time
id: getDateTime
run: echo "result=$(TZ=Asia/Shanghai date)" >> "$GITHUB_OUTPUT"
- name: Create or update a comment
uses: ./.github/actions/unique-comment
with:
uniqueIdentifier: ${{ github.workflow }}
body: |
## Eslint Check Result

${{needs.eslint.outputs.result}}

## Typescript Check Result

${{needs.typescript.outputs.result}}

## UnitTest Check Result

${{needs.unitTest.outputs.result}}

---

Commented by Action [${{github.workflow}}](${{github.event.repository.html_url}}/actions/runs/${{github.run_id}}), last updated on ${{steps.getDateTime.outputs.result}}.

和replyChecking差不多,在body里使用${{needs.eslint.outputs.result}}去读取了eslint job的outputs。

测试

去发起新的pr,故意提交一个有eslint error的js/ts文件,看看表现吧~