添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
一起编写个多用途 Github Action 吧

一起编写个多用途 Github Action 吧

1 年前 · 来自专栏 代码与工程化

前言

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

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

快速开始

0. 从模板初始化项目

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

1. 在根目录添加 action.yml

这个文件是用来告诉 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. 创建入口 index.ts

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

3. 获取参数以及 github 上下文

这里就需要介绍 @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_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 软件,然后扫描 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. 添加条件变量,并统筹 action npm 包的写法

这里我们添加一个 __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 实例

我们获取 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

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

  • npmOutput actionOutput 2 rollup#OutputOptions
  • tsconfig.action.json tsconfig.build.json 2 ts 配置。

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",