一、复合操作到底是什么,为什么要自己封装

先别急着写代码,咱们先把概念捋一捋。GitHub Actions 里有一种叫“复合操作”的封装方式,你可以把它理解成一个预先组装好的工具箱。平时工作流里那些又长又容易出错的步骤,比如安装依赖、跑测试、上传产物,如果每次都复制粘贴一遍,不仅看着累,后面改起来更是要命。复合操作允许你把多个步骤塞进一个 action 里,然后在别的 workflow 里像调用函数一样调用它。

我自己第一次接触的时候,想法特别简单:不就是把几个 run 命令打包一下嘛。但真正用起来,才发现里面有挺多讲究。尤其是参数类型校验、工作目录和上下文读取这两块,稍不注意就会掉进坑里。今天咱们就从实际场景出发,把这些坑一个个踩平。

二、参数类型校验:你以为传的是数字,其实是个字符串

2.1 输入参数定义起来很简单

先看一个最简单的复合操作。假设咱们想封装一个“打印部署环境”的步骤,它接收一个名称参数和一个超时时间。在 action 的 YAML 配置文件里定义输入参数,看起来特别直观。

# action.yml
name: 'Print Deploy Info'
description: '打印部署相关信息,并做简单校验'
inputs:
  env_name:
    description: '环境名称,比如 staging 或者 prod'
    required: true
  timeout:
    description: '超时秒数,预期是数字'
    required: false
    default: '30'
runs:
  using: 'composite'
  steps:
    - name: Show inputs
      run: |
        echo "当前环境:${{ inputs.env_name }}"
        echo "超时时间:${{ inputs.timeout }}"
      shell: bash

这段配置看着没问题,但有个隐患:复合操作的输入参数没有真正的“数字类型”。虽然你在定义时可以声明类型,但在实际使用中,所有输入都会以字符串形式进入运行环境。也就是说,你定义了一个 timeout,默认是 30,可它本质上还是字符串“30”。

2.2 验证时最容易翻车

如果咱们想写个脚本,判断超时时间是不是大于 60 秒,直接在 shell 里比较,会出现一个很有意思的情况:

# 这个代码看起来对,但字符串比较会出错
if [ "${{ inputs.timeout }}" -gt 60 ]; then
  echo "超时时间设置很大"
fi

在 bash 里用 -gt 做整数比较,如果输入正好是纯数字字符串,Bash 会尝试转成整数。可如果输入是个不干净的格式,比如“30s”,那整个 action 就报错了。更隐蔽的是,如果你用布尔值作为参数,比如 needs_cleanup 的值是 true,读取过来是字符串“true”,在一些严谨的脚本里判断也许没问题,但你一旦习惯性地写成直接执行表达式,那就把 true 当成了命令,整个步骤会直接炸掉。

2.3 规范做法:先定边界,再收数据

封装前,咱们应该想清楚这个参数到底允许什么格式。是只接受固定几个值?还是必须是数字?如果允许用户填 staging 或 prod,最好在 action 内部做一遍白名单校验。下面是我自己比较常用的校验方式,简单又有效。

# action.yml
name: 'Validate Deploy'
description: '校验环境名称,并把超时时间转换为真实数字'
inputs:
  env_name:
    description: '只能填 staging 或 prod'
    required: true
  timeout:
    description: '超时秒数,必须是整数'
    required: true
    default: '30'
runs:
  using: 'composite'
  steps:
    - name: Check env_name
      run: |
        # 注意这里要加双引号,防止字符串里带着空格
        if [ "${{ inputs.env_name }}" != "staging" ] && [ "${{ inputs.env_name }}" != "prod" ]; then
          echo "环境名称不合法,只能填 staging 或 prod"
          exit 1
        fi
        echo "环境名称检查通过"
      shell: bash

    - name: Check timeout
      run: |
        # 用 grep 来校验是否全是数字
        if ! echo "${{ inputs.timeout }}" | grep -qE '^[0-9]+$'; then
          echo "超时时间必须是正整数"
          exit 1
        fi
        echo "超时时间校验通过"
      shell: bash

这里有两个要点:一是所有输入都放在双引号里,避免特殊字符把命令行搞乱;二是校验逻辑直接写在 action 内部,发现问题就退出,让 workflow 立刻失败。这样用户用的时候就特别安心,因为咱们提前替他兜住了错误。

三、工作目录:默认在哪儿,你说了不算

3.1 一个让人头疼的默认行为

关于复合操作的工作目录,很多新手都栽过跟头。你以为在 action 的 YAML 配置文件里写的 run 命令,会以 action 所在的目录为基准执行?并不是。默认情况下,复合操作里的步骤和普通 workflow 一样,工作目录都是仓库的根目录,也就是 github.workspace 这个上下文指向的目录。如果你在 action 的 YAML 配置文件里写了一个简单的 ls 命令,看到的不是 action 目录下的文件,而是主仓库根目录下的文件。

举个例子,假如你的复合操作想要读取它自己目录下的一份配置模板,直接写相对路径是读不到的。很多人会尝试:

# 这样跑会找不到文件,因为工作目录在仓库根
cat ./config-template.yml

然后就开始怀疑人生:这个文件明明就在 action 目录里啊!问题就是当前工作目录已经切换了。正确做法是用一个特殊变量,GitHub Actions 里默认会把你这个复合操作所在的仓库的绝对路径放在一个环境变量里,但更通用的办法是通过一个叫 GITHUB_ACTION_PATH 的环境变量来定位。

3.2 怎么正确找到自己的根

复合操作其实是在运行时被拉取到某个临时路径下的,你可以在 action 的 YAML 配置文件的 run 中,通过读这个环境变量来拿到 action 所在的目录。不过这个变量很多文档里不常强调,用的时候要特别小心。下面是一个完整的示例,思路是先把 action 的目录存到一个变量里,然后再读取同目录下的模板文件。

# scripts/use-template.sh
#!/usr/bin/env bash
# 拿到当前 action 所在的目录,注意去掉末尾斜杠
ACTION_DIR="${GITHUB_ACTION_PATH%/}"
TEMPLATE_FILE="$ACTION_DIR/config-template.yml"

echo "Action 目录:$ACTION_DIR"
echo "模板文件路径:$TEMPLATE_FILE"

if [ -f "$TEMPLATE_FILE" ]; then
  echo "模板文件存在,内容如下:"
  cat "$TEMPLATE_FILE"
else
  echo "没有找到模板文件,请检查目录结构"
  exit 1
fi

在 action 的 YAML 配置文件里调用这个脚本时,shell 的默认工作目录仍然是仓库根,但我们的脚本已经根据 GITHUB_ACTION_PATH 找到了正确位置。当然,如果你只是想让某条命令在指定目录下执行,可以直接在 run 里加一个 cd 命令,但别忘了使用原工作目录的路径时先保存下来。

3.3 官方约定:别把自己绕晕

GitHub 官方其实也建议,复合操作内部的步骤应该避免依赖当前工作目录,因为用户可能通过各种方式调用,比如从某个 fork 的仓库调用,或者用相对路径。所以最稳妥的办法就是显式地使用绝对路径,或者显式地切换目录。在 workflow 里,你可以用 working-directory 来指定步骤的工作目录,但你在封装复合操作时,却没法直接给每个 step 设置一个相对于 action 自身位置的 working-directory。这时候,前面提到的 GITHUB_ACTION_PATH 就成了救命稻草。

四、上下文读取:表达式和 env 的关系

4.1 什么时候能用表达式

复合操作的步骤里,你可以使用很多 GitHub Actions 的上下文,比如 inputs、github、env 等等。但有一个冷知识:这些上下文只在 YAML 解析的时候被替换。你在 run 里直接写一条命令,把输入参数插进去,实际上是在把整个 YAML 传给 runner 之前,表达式就已经被替换成了实际值。换句话说,你根本看不到原样的表达式,它们都被替换成字符串了。

这就引出一个问题:如果用户的输入里带有特殊的 shell 字符,比如一段可怕的子命令替换,直接拼接进命令就会非常危险。所以咱们在校验或处理输入时,最好先用环境变量把值接住,然后再通过变量的方式访问,而不是直接拼在 shell 命令里。GitHub 官方也推荐使用 env 来传递值。

4.2 从上下文中读取并构造变量

下面这段示例展示了怎么在复合操作里,把输入和仓库信息一起组合成一条消息,并暴露成输出,供后续步骤使用。

# action.yml
name: 'Build Message'
description: '组装一条包含仓库和提交信息的消息'
inputs:
  message:
    description: '自定义消息内容'
    required: true
outputs:
  full_message:
    description: '组合后的完整消息'
runs:
  using: 'composite'
  steps:
    - name: Build the full message
      id: msg
      shell: bash
      run: |
        # 这里把可能包含特殊字符的输入放到环境变量里
        MY_MSG="${{ inputs.message }}"
        REPO_NAME="${{ github.repository }}"
        COMMIT_SHA="${{ github.sha }}"

        FULL_MSG="[${REPO_NAME}] ${COMMIT_SHA:0:7} - ${MY_MSG}"
        echo "full_message=${FULL_MSG}" >> "$GITHUB_OUTPUT"
        echo "完整消息已生成:$FULL_MSG"

这里我们用了 GITHUB_OUTPUT 这个环境变量来写输出,老版本的 set-output 已经被官方废弃了,别再用了。写输出的时候,值里面可能会有换行或特殊字符,所以最好用定界符方式,上面的例子稍微简化了一下,实际更稳妥的是用 cat 加 EOF 的形式,可以避免乱入的引号。

4.3 读取和传递 secrets 的小心机

复合操作里经常需要用到密钥。你可以直接在 workflow 里给复合操作传 secrets,比如在步骤里输入 token 参数。但要注意,在复合操作的内部配置里,你不能直接读取 secrets 上下文,只能通过输入参数把密钥传进来。所以常见做法是在 action 的 YAML 配置文件里定义一个 token 输入,然后在 workflow 里把它传进去。别把密钥硬编码到仓库文件里,这是基本底线。

五、应用场景、技术优缺点与注意事项

5.1 哪些场景适合封装

最容易想到的场景是多仓库复用。比如你公司有十几个代码仓库,每个都要做同样的构建、测试和部署流程。把这些流程封装成一个复合操作,放到一个专门的仓库里,然后每个项目的 workflow 只需要几行调用即可。另一个场景是单仓库内减少重复。哪怕只在同一个仓库里,不同的 workflow 之间也经常有重复的初始化步骤,封装后能大幅减少 YAML 的体积。

5.2 技术优缺点

复合操作的优点很明显:复用性高,逻辑集中,修改一次,所有调用方都生效。而且它比基于 Docker 的 action 轻量,因为不需要构建镜像,跑的就是 shell 或脚本。

缺点也很现实:调试不太方便,因为错误信息有时候只出现在 runner 日志里,没法在本地实现完整的交互式调试。另外,参数校验不像编程语言那样严格,所有输入都靠你手动检查,稍不谨慎就埋雷。还有版本管理的问题,调用方如果不小心引用了尚未发布的版本,很容易被破坏性更新坑到。

5.3 封装修行指南

我自己的经验是:封装之前一定列出清晰的边界,比如这个 action 允许哪些输入、输出哪些值、工作目录如何约定、是否允许使用外部的 shell 命令。然后尽量沿用官方约定,比如在配置里声明使用复合操作,步骤里明确写 shell 类型,用 env 传递变量,用 GITHUB_OUTPUT 写输出。还要考虑用户的使用体验,输入默认值要合理,错误信息要友好,能提前退出就别拖到后面。

另一个注意事项是不要在 action 的 YAML 配置文件里写死绝对路径,因为调用环境不同,路径会变。尽量依赖上下文变量,比如 github.workspace 表示主仓库根目录,GITHUB_ACTION_PATH 表示当前 action 的目录。如果需要在两个目录之间切换,建议先把原始路径保存到一个变量里。

六、文章总结

回头看,复合操作其实不难,难的是那些藏在细节里的默认行为和上下文的替换规则。参数类型不能只看表面,工作目录不能凭直觉,上下文读取也要讲究方法。只要咱们在封装前把边界梳理清楚,所有输入输出白纸黑字写明白,并且沿用官方的推荐写法,这些坑完全可以避开。下次你再打算封装一个复合操作的时候,不妨先用十分钟想清楚三个问题:这个操作允许哪些输入?运行时的仓库里到底有哪些路径?输出值该用什么方式暴露?想清楚这些问题,你已经比大多数踩坑的人先走了一大步。