vlambda博客
学习文章列表

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


前言

Github Actions 想必大家或多或少都了解,并使用过类似的产品。

这篇文章就从开发,测试,构建的角度来设计一个 Github Action,让它可以便捷的复用代码逻辑,并同时发布到 Github Marketplace, npm 等平台。

快速开始

0. 从模板初始化项目

快速创建一个 ts rollup lib 项目,本人一般使用自己的模板(sonofmagic/npm-lib-rollup-template),当然这无所谓,自己 npm init -y 也是可以的。

1. 在根目录添加 

这个文件是用来告诉 Github 这个仓库是一个 Action,Github 指南中给的示例如下:

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. 描述类: name,author,description 这些字段来描述这个 action 是什么

  2. 入参: inputs 下的字段,用来给 action 传参

  3. 出参: outputs 下的字段,用于定义出参字段

  4. runs: 用于定义运行时相关的配置,JavaScript action 和 Docker 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. 创建入口 

async function main(){
  // do something}main()

3. 获取参数以及 

这里就需要介绍 @actions/core 和 @actions/github

@actions/core 里面包含了大量 action 的核心方法,我们获取参数,导出变量,或者获取秘钥等等都得靠它。

@actions/github 则主要包含了 Github 的上下文和一个 @octokit/core,它能够直接帮助我们调用 Github 的 rest 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_TOKENoctokit = 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 软件,然后扫描 Github 的 Two factor 绑定的二维码,这样你的 Github Action 就被顺利的发布到了 插件市场 里了。

庆祝一下你的成功吧!

开始进阶之旅

当然笔者远不止想介绍这么多,不然标题的 多用途 三个字就没提现出来。

接下来我们同时要把这个包的主逻辑抽离出来,发布成 npm 包,再通过 mock 的上下文,构建单元测试用例。具体怎么做呢?

核心其实很简单:代码分割 和 条件编译

0. 条件编译

我们开发者对这个再熟悉不过了,通过条件编译可以直接去除一些 unreachable code,比如我们发布成 npm 包给用户用,自然是不需要 @actions/core 和 @actions/github 的。 那么就可以在打包时直接把它们干掉。

实现它的手段很多,比如 webpack.DefinePlugin,@rollup/plugin-replace,esbuild#define 等等。

1. 代码分割

这个借助打包工具也很容易实现,比如我们原先引入是用静态写法:

import { getActionOptions } from './action'

接下来我们改为 async/await动态引入

async function mian() {
  const { getActionOptions } = await import('./action')}

通过这种方式,打包工具除了默认的 output 配置,会生成 [name].js 的 entryFile 外,还会生成一些 [name]-[hash].js 的 chunkFile,来交给运行时动态加载。

2. 添加条件变量,并统筹 

这里我们添加一个 __isAction__ 的布尔值变量

declare var __isAction__: boolean

对于 action 和 npm 的不同,主要在于它们的入参出参方式不同,还有上下文不同。

那么我们就可以根据这 2 点,进行编译时重载:

3. 重载获取参数

我们获取参数就可以这么写:

export async function getOptions (
  options?: UserDefinedOptions): Promise<UserDefinedOptions> {
  let opt: Partial<UserDefinedOptions>

  if (__isAction__) {
    const { getActionOptions } = await import('./action')
    opt = getActionOptions()
  } else {
    opt = options
  }
  return defu<Partial<UserDefinedOptions>, UserDefinedOptions>(
    opt,
    getDefaults()
  ) as UserDefinedOptions}

这样在打包时就能确定代码的走向。

4. 重载获取 

我们获取 Octokit 实例就可以这么写:

const { token } = optionslet octokitif (__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

其中可以看到,打包的配置也随着构建目标不同,使用了不同的配置。比如:

  • npmOutput 与 actionOutput 这 2 个 rollup#OutputOptions

  • tsconfig.action.json 和 tsconfig.build.json 这 2 个 ts 配置。

6. 发布到 

在 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 Actions / Creating actions (指南)

Metadata syntax for GitHub Actions

源代码

github-repository-distributor