一起编写个多用途|一起编写个多用途 Github Action 吧

  • 一起编写个多用途 Github Action 吧
    • 前言
    • 快速开始
      • 0. 从模板初始化项目
      • 1. 在根目录添加 action.yml
      • 2. 创建入口 index.ts
      • 3. 获取参数以及 github 上下文
      • 4. 在你的 main 函数填入逻辑
      • 5. 把结果打包输出到指定目录
      • 6. 发布到 github marketplace
    • 开始进阶之旅
      • 0. 条件编译
      • 1. 代码分割
      • 2. 添加条件变量,并统筹 actionnpm 包的写法
        • 3. 重载获取参数
        • 4. 重载获取 Octokit 实例
      • 5. 更改打包配置
      • 6. 发布到 npm
    • 单元测试
    • 结尾
    • 参考文档
    • 源代码
一起编写个多用途 Github Action 吧 前言 Github Actions 想必大家或多或少都了解,并使用过类似的产品。
这篇文章就从开发,测试,构建的角度来设计一个 Github Action,让它可以便捷的复用代码逻辑,并同时发布到 Github Marketplace, npm 等平台。
快速开始 0. 从模板初始化项目
快速创建一个 ts rollup lib 项目,本人一般使用自己的模板(sonofmagic/npm-lib-rollup-template),当然这无所谓,自己 npm init -y 也是可以的。
1. 在根目录添加 action.yml
这个文件是用来告诉 Github 这个仓库是一个 ActionGithub 指南中给的示例如下:
name: 'Hello World' # 必填 Required GitHub Action 名称 description: 'Greet someone and record the time' # 必填 Required 描述 inputs: # 输入 who-to-greet:# id of input description: 'Who to greet' # 参数描述 required: true # 是否必填 default: 'World' # 此参数是一个字符串,文档中没有注明其他的类型 outputs: # 输出 time: # id of output description: 'The time we greeted you' runs: using: 'node16' # 运行时 main: 'index.js' # 执行入口

从这个配置文件中,我们大体可以分为 5 类元数据:
  1. 描述类: nameauthordescription 这些字段来描述这个 action 是什么
  2. 入参: inputs 下的字段,用来给 action 传参
  3. 出参: outputs 下的字段,用于定义出参字段
  4. runs: 用于定义运行时相关的配置,JavaScript actionDocker container action 有不同的配置。这篇文章主要介绍的是 JavaScript action
  5. 样式相关: branding 字段主要用于上架到 Github Marketplace 上的 icon 和颜色。
这样我们就可以定义自己的元数据 action.yml:
name: 'github-repository-distributor' description: 'github-repository-distributor' inputs: token: # id of input description: 'the repo PAT or GITHUB_TOKEN' required: true username: description: 'github username to generate markdown files' required: true motto: description: 'whether add powered by footer (boolean)' default: 'true' # 注意这里是字符串 # .... title: description: 'main markdown h1 title' onlyPrivate: description: 'only include private repos (boolean)' default: 'false' runs: using: 'node16' main: 'lib/index.js' branding: icon: 'arrow-up-circle' color: 'green'

2. 创建入口 index.ts
async function main(){ // do something } main()

3. 获取参数以及 github 上下文
这里就需要介绍 @actions/core@actions/github
@actions/core 里面包含了大量 action 的核心方法,我们获取参数,导出变量,或者获取秘钥等等都得靠它。
@actions/github 则主要包含了 Github 的上下文和一个 @octokit/core,它能够直接帮助我们调用 Githubrest api 接口们。
这样我们获取 inputs 里的参数就可以这么写:
import core from '@actions/core' import type { UserDefinedOptions } from './type'export function getActionOptions (): UserDefinedOptions { const token = core.getInput('token') const username = core.getInput('username') // getBooleanInput 其实本质上就是一种 parseBoolean(core.getInput('key')) const motto = core.getBooleanInput('motto') const filepath = core.getInput('filepath') const title = core.getInput('title') const includeFork = core.getBooleanInput('includeFork') const includeArchived = core.getBooleanInput('includeArchived') const onlyPrivate = core.getBooleanInput('onlyPrivate') return { token, username, motto, filepath, title, includeFork, includeArchived, onlyPrivate } }

当然我们也可以轻而易举的获取到上下文里的信息和 octokit 实例:
import github from '@actions/github' // 使用action的仓库名 github.context.repo.repo // token 为 the repo PAT or GITHUB_TOKEN octokit = github.getOctokit(token) // 获取一个人的仓库 const res = await octokit.rest.repos.listForUser({ username: 'sonofmagic', per_page: 20, page: 1, sort: 'updated' })

4. 在你的 main 函数填入逻辑
我们回到入口点,在代码中填充逻辑
async function main(){ const options = getActionOptions() // do something } main()

5. 把结果打包输出到指定目录
这里我把打包结果输出到了 lib 文件中,值得注意的是,官方文档中是使用 @vercel/ncc(webpack),同时还把 node_modules/* 也提交到 Github 上。这里我们优化一下,采用了 rollup 打包,直接把依赖项打入构建产物中。
import typescript from '@rollup/plugin-typescript' import { nodeResolve } from '@rollup/plugin-node-resolve' import commonjs from '@rollup/plugin-commonjs' import json from '@rollup/plugin-json' import pkg from './package.json' import { terser } from 'rollup-plugin-terser'const isDev = process.env.NODE_ENV === 'development'/** @type {import('rollup').RollupOptions} */ const config = { input: 'src/index.ts', output: { dir: 'lib', format: 'cjs', exports: 'auto' }, plugins: [ // 嫌弃 lib 太大可以压缩一下 terser(), json(), nodeResolve({ preferBuiltins: true }), commonjs(), typescript({ tsconfig: './tsconfig.build.json', sourceMap: isDev }) ], external: [ ...(pkg.dependencies ? Object.keys(pkg.dependencies) : []), 'fs/promises' ] }export default config

然后再 git add lib/* 添加构建产物,提交。这样, lib 中大量的 "无用" 代码也被提交到了 Github
6. 发布到 github marketplace
在手机上下载微软的 Authenticator 软件,然后扫描 GithubTwo factor 绑定的二维码,这样你的 Github Action 就被顺利的发布到了 插件市场 里了。
庆祝一下你的成功吧!
开始进阶之旅 当然笔者远不止想介绍这么多,不然标题的 多用途 三个字就没提现出来。
接下来我们同时要把这个包的主逻辑抽离出来,发布成 npm 包,再通过 mock 的上下文,构建单元测试用例。具体怎么做呢?
核心其实很简单:代码分割条件编译
0. 条件编译
我们开发者对这个再熟悉不过了,通过条件编译可以直接去除一些 unreachable code,比如我们发布成 npm 包给用户用,自然是不需要 @actions/core@actions/github 的。 那么就可以在打包时直接把它们干掉。
实现它的手段很多,比如 webpack.DefinePlugin@rollup/plugin-replaceesbuild#define 等等。
1. 代码分割
这个借助打包工具也很容易实现,比如我们原先引入是用静态写法:
import { getActionOptions } from './action'

接下来我们改为 async/await动态引入
async function mian() { const { getActionOptions } = await import('./action') }

通过这种方式,打包工具除了默认的 output 配置,会生成 [name].jsentryFile 外,还会生成一些 [name]-[hash].jschunkFile,来交给运行时动态加载。
2. 添加条件变量,并统筹 actionnpm 包的写法
这里我们添加一个 __isAction__ 的布尔值变量
declare var __isAction__: boolean

对于 actionnpm 的不同,主要在于它们的入参出参方式不同,还有上下文不同。
那么我们就可以根据这 2 点,进行编译时重载:
3. 重载获取参数 我们获取参数就可以这么写:
export async function getOptions ( options?: UserDefinedOptions ): Promise { let opt: Partialif (__isAction__) { const { getActionOptions } = await import('./action') opt = getActionOptions() } else { opt = options } return defu, UserDefinedOptions>( opt, getDefaults() ) as UserDefinedOptions }

这样在打包时就能确定代码的走向。
4. 重载获取 Octokit 实例 我们获取 Octokit 实例就可以这么写:
const { token } = options let octokit if (__isAction__) { const { github } = await import('./action') octokit = github.getOctokit(token) } else { const { Octokit } = await import('@octokit/rest') // require() octokit = new Octokit({ auth: token }) }

这样 action@actions/github,默认情况下走 @octokit/rest,获得的 Octokit 也是一致的。
5. 更改打包配置
我们添加 BUILD_TARGET 环境变量,当值为 action 打包 Action,默认为 npm 包。
这样我们很容易可以编写出这样的 rollup.config.js:
import typescript from '@rollup/plugin-typescript' import { nodeResolve } from '@rollup/plugin-node-resolve' import commonjs from '@rollup/plugin-commonjs' import json from '@rollup/plugin-json' import pkg from './package.json' import replace from '@rollup/plugin-replace' import { terser } from 'rollup-plugin-terser'const isDev = process.env.NODE_ENV === 'development' const isAction = process.env.BUILD_TARGET === 'action'/** @type {import('rollup').OutputOptions} */ const npmOutput = { file: pkg.main, format: 'cjs', sourcemap: isDev, exports: 'auto' }/** @type {import('rollup').OutputOptions} */ const actionOutput = { dir: 'lib', format: 'cjs', exports: 'auto' }/** @type {import('rollup').RollupOptions} */ const config = { input: 'src/index.ts', output: isAction ? actionOutput : npmOutput, plugins: [ isAction ? terser() : undefined, replace({ preventAssignment: true, values: { __isAction__: JSON.stringify(isAction) } }), json(), nodeResolve({ preferBuiltins: true }), commonjs(), typescript({ tsconfig: isAction ? './tsconfig.action.json' : './tsconfig.build.json', sourceMap: isDev }) ], external: [ ...(pkg.dependencies ? Object.keys(pkg.dependencies) : []), 'fs/promises' ] }export default config

其中可以看到,打包的配置也随着构建目标不同,使用了不同的配置。比如:
  • npmOutputactionOutput2rollup#OutputOptions
  • tsconfig.action.jsontsconfig.build.json2ts 配置。
6. 发布到 npm
package.json 中添加打包指令和 npm 包括文件吧!
{ "scripts":{ "build": "yarn clean && yarn dts && cross-env NODE_ENV=production rollup -c", "build:action": "yarn clean lib && cross-env NODE_ENV=production BUILD_TARGET=action rollup -c", }, "files": [ "dist" ] }

构建完成后,执行 yarn publish,大功告成!
单元测试 其实测试也是同样的道理,在单元测试用例执行之前,可以劫持获取参数的方法和获取 github 上下文的方法,通过这样来进行单元测试。
结尾 出于篇幅限制,本篇文章并未就细节过多介绍。主要给大家编写 Github Action 一个思路,如果各位有兴趣可以一起探讨。
参考文档 Debug your GitHub Actions by using tmate
上架 github marketplace 地址
GitHub Actions / Creating actions (指南)
Metadata syntax for GitHub Actions
源代码 【一起编写个多用途|一起编写个多用途 Github Action 吧】github-repository-distributor

    推荐阅读