一、问题初现:一条看似无害的条件判断

前几天我在折腾一个GitHub Actions工作流,想实现一个特别简单的功能:只有PR合到main分支的时候,才跑那个耗时很长的集成测试。我信心满满地在step里写了这么一句:

# 技术栈:YAML(GitHub Actions workflow 配置)
name: demo
on:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: 只有main分支才跑
        run: echo "终于跑了一次"
        if: ${{ github.event.pull_request.base.ref == 'main' }}

看着挺对的,对吧?但实际跑起来,有时候明明PR目标分支是main,它却不跑。我又检查了一遍,发现居然是我把字符串比较和YAML的布尔值习惯搞混了。从那天起,我就意识到,如果不搞清楚GitHub Actions表达式里的类型转换规则,这种小坑能让人折腾一下午。

这篇文章就用最实在的话,把字符串比较、数组处理以及上下文类型转换这几件事掰开揉碎了讲清楚。

二、字符串比较的那些“坑”

2.1 永远别用 “=” 来比较字符串

很多人在写代码时习惯了 = 表示赋值或比较,但在GitHub Actions表达式里,比较运算符只有两个:==!=。如果你不小心写了 =,表达式不会报错,但结果往往不是你想要的。因为 = 会被解读为“设置值”,而表达式上下文里这种写法没有意义,最后返回的可能是空值或者false,直接把你的条件变成永远不成立。

正确写法:

# 技术栈:YAML(GitHub Actions workflow 配置)
- name: 用正确的方式比较
  run: echo "使用了双等号"
  if: ${{ github.event_name == 'push' }}

还要注意,这里没有 ===,JavaScript里的严格等号习惯也要扔掉。它只有一种宽松的相等比较,背后会做自动类型转换。

2.2 引号不是装饰,是保命符

在YAML里写表达式时,很多开发者会忽略引号对上下文的影响。比如你想判断一个变量是否是字符串“yes”,如果不加引号:

# 技术栈:YAML(GitHub Actions workflow 配置)
if: ${{ inputs.flag == yes }}   # 有问题

这里的 yes 会被YAML解析成布尔值 true,而不是字符串。再比如字符串“on”也会被转换成布尔值。这就是为什么在表达式里比较字符串时,最好把两侧都加上双引号,并且整个表达式用单引号包裹:

# 技术栈:YAML(GitHub Actions workflow 配置)
if: ${{ inputs.flag == 'yes' }}   # 推荐做法

2.3 字符串和布尔值之间那笔糊涂账

最让人摸不着头脑的就是字符串和布尔值的比较了。假设你从输入框里拿到一个值,你想判断它是不是“true”。于是写了:

# 技术栈:YAML(GitHub Actions workflow 配置)
if: ${{ inputs.debug == true }}

如果输入框传过来的是字符串 "true",你猜结果是什么?是 true 吗?不一定。GitHub Actions的表达式在做类型转换时,会把字符串值先尝试转成布尔:常见的“true”、“yes”、“on”等可能被转成 true,但如果在某些上下文里保留了字符串类型,那么 "true" 和布尔值 true 可能就是不相等。为了避免这种模糊,最可靠的办法是显式转换:

# 技术栈:YAML(GitHub Actions workflow 配置)
if: ${{ inputs.debug == 'true' }}

把两边的类型统一成字符串,就不会有歧义了。

2.4 大小写太无情

GitHub Actions表达式的字符串比较是区分大小写的,Mainmain 完全是两个东西。我在一次代码评审里就看到有人因为分支名写成了 Main,结果条件一直不触发。

想忽略大小写,可以用 toLowerCase() 函数:

# 技术栈:YAML(GitHub Actions workflow 配置)
if: ${{ toLowerCase(github.event.pull_request.base.ref) == 'main' }}

这样即使人家传进来的是 Main,也能正确识别。

三、数组处理:小心隐式转换

3.1 contains() 函数是怎么工作的?

contains() 是GitHub Actions表达式里最常用的数组函数。它的原型是 contains(容器, 值)。当容器是数组时,它会逐个比较数组里的元素和第二个参数。这里就有坑了:它使用的是宽松比较还是严格比较?实际上,它在比较前会把两边都转换成字符串,然后再做相等判断。

比如:

# 技术栈:YAML(GitHub Actions workflow 配置)
- name: 数组中包含字符串
  run: echo "包含"
  if: ${{ contains(github.event.pull_request.labels.*.name, 'bug') }}

这个通常没问题,因为数组元素和第二个参数都是字符串。但是如果数组里是数字,比如 [1, 2, 3],你判断 contains([1,2,3], '2'),它会先把这个数组的每个元素转成字符串,所以结果是 true。反过来,如果你判断 contains(['1', '2', '3'], 2),它也会把 2 转成 '2',一样是 true。这种隐式转换有时候能帮上忙,但也会造成误判。比如你想精确区分数字 2 和字符串 '2',用 contains 根本做不到。

3.2 数组里的布尔值也是坑

再看一个更隐蔽的:

# 技术栈:YAML(GitHub Actions workflow 配置)
- name: 判断数组里是否有true
  run: echo "找到了"
  if: ${{ contains([true, false], 'true') }}

你猜结果是什么?由于数组元素是布尔值,第二个参数是字符串,contains 会把布尔值转成字符串,所以 true 变成了 'true',结果就是 true。但如果你比较的是数组里的数字 1 和字符串 'true',显然不成立。这种转换规则虽然直观,但很容易让不熟悉的人踩坑。

3.3 空数组的逆天表现

在GitHub Actions表达式中,一个空数组如果直接作为条件,会被转成什么?很多人会认为空数组是“空”的,应该和 false 等价。但事实不是这样。因为表达式里没有明确定义“空数组”的布尔含义,它在某些情况下会被当成“真值”,导致你的if条件突然成立。举一个实际场景:

# 技术栈:YAML(GitHub Actions workflow 配置)
- name: 当标签数组不为空时才执行
  run: echo "有标签"
  if: ${{ github.event.pull_request.labels }}

如果你原本想表达“标签数组非空”,这个写法就不够可靠。因为当数组为空时,它可能被转换成 true 或者一个非空对象,从而错误地执行了步骤。正确做法是:

# 技术栈:YAML(GitHub Actions workflow 配置)
- name: 用 !empty 判断
  run: echo "有标签"
  if: ${{ !empty(github.event.pull_request.labels) }}

注意,empty() 是一个用于检查字符串、数组或对象是否为空的函数。用这个就清楚多了。

3.4 用 fromJSON 强制指定类型

如果你拿到一个JSON格式的字符串,想让它参与数组比较,最好先用 fromJSON 把它解码成真正的数组或对象。这样能避免字符串和数组之间的模糊转换。举个例子:

# 技术栈:YAML(GitHub Actions workflow 配置)
- name: 使用fromJSON转换
  run: echo "数字2在数组里"
  if: ${{ contains(fromJSON('["1", "2", "3"]'), '2') }}

这里 fromJSON('["1", "2", "3"]') 返回的字符串数组 ["1","2","3"],然后 contains 就会明确逐个元素做字符串比较。如果你不对JSON字符串做转换,contains 会把整个字符串当成一个对象,结果往往不是你预期的。所以当数据从外部传入时,fromJSON 是帮你控制类型的利器。

四、实战场景:写一个可靠的条件

我们把上面这些知识点串起来,写一个真正有价值的例子:仅当PR带有 bug 标签,并且目标分支是 main,同时当前仓库不是fork(可选)时,才运行调试任务。

完整示例:

# 技术栈:YAML(GitHub Actions workflow 配置)
name: reliable-condition

on:
  pull_request:

jobs:
  debug:
    runs-on: ubuntu-latest
    steps:
      - name: 检查目标分支
        run: echo "目标分支是main"
        if: ${{ github.event.pull_request.base.ref == 'main' }}

      - name: 检查bug标签
        run: echo "带有bug标签"
        # 使用 contains 检查 labels 数组里是否有字符串 "bug"
        # labels 是 PR 上的标签列表,每个标签对象里都有 name 属性
        if: ${{ contains(github.event.pull_request.labels.*.name, 'bug') }}

      - name: 两个条件都满足才执行
        run: |
          echo "开始执行调试"
          echo "分支: ${{ github.event.pull_request.base.ref }}"
        # 注意:这里用 && 连接两个条件
        # 左侧字符串比较已经带上引号,右侧 contains 的第二个参数也用引号,防止类型转换
        if: ${{ github.event.pull_request.base.ref == 'main' && contains(github.event.pull_request.labels.*.name, 'bug') }}

这个示例里我们做了两件事:第一,字符串比较时,两边都使用显式引号;第二,数组检查时,使用 contains 并且第二个参数写成了字符串。这样就不会出现 'bug' 和布尔值 true 之类的混淆。

但是要注意,如果PR没有任何标签,github.event.pull_request.labels 是空数组,labels.*.name 也会变成空数组,contains 会正常返回 false,不会报错。这比直接拿数组当条件靠谱得多。

五、技术优缺点与适用范围

5.1 GitHub Actions表达式的优点

我觉得它值得称赞的地方是轻量,不依赖任何编程语言,直接在YAML里就能写条件判断。对于简单的逻辑,比如判断事件类型、分支、路径过滤,非常直观。而且它内置了不少函数,像 containsstartsWithendsWithtoLowerCase 等,覆盖了日常需求。它还支持 &&||! 这些逻辑运算符,能够组合出比较丰富的条件。最关键的是,你不需要额外写代码,所有配置都在一个文件里完成,维护成本低。

5.2 缺点和雷区

它的类型转换规则实在不够直观。默认的宽松比较在很多情况下会突然改变结果,而且没有一个类似“严格模式”的开关。另外,调试比较困难。表达式的结果不会直接打印在日志上,一旦条件不满足,你只能靠猜。还有一点,数组与对象之间的转换规则在不同版本里可能会有细微差别,很容易产生“本地没问题、云端就挂”的现象。更麻烦的是,YAML解析器和表达式解析器会先后对字符串进行处理,你稍不注意写出的引号就会被吞掉,导致条件变成一团乱麻。

5.3 什么时候该用,什么时候该避开

如果条件逻辑只有两三个判断,用表达式完全没有问题。但如果你的判断逻辑超过五行,或者包含复杂的嵌套、循环、类型转换,那么我建议你别硬撑。可以把它抽成一个JavaScript action或一个Shell脚本,在里面用成熟的编程语言来处理,反而更可靠。表达式适合做“门卫”,不适合做“业务大脑”。换句话说,它用来挡掉不需要跑的任务很好用,但如果你需要在多个数组和对象之间做精细判断,还是交给代码更省心。

六、注意事项汇总

  • 永远使用 ==!=,不要使用 ====
  • 在 YAML 中比较字符串时,尽量给两侧字符串加上双引号,避免被解析成布尔值。
  • 整个表达式最好用 ${{ ... }} 包裹,并且在YAML里用单引号包裹整个值,防止特殊字符被转义。
  • 数组比较时,明确 contains() 会把两侧转成字符串再比较,不要依赖隐式转换来区分类型。
  • 不要直接用数组或对象作为 if 条件,空数组和空对象在某些上下文中可能是“真值”,用 empty() 函数或 .size 来判断。
  • 分支名、标签名都区分大小写,统一用 toLowerCase() 再比较才稳妥。
  • 如果条件复杂,优先考虑写成外部 action 或脚本,而不是堆叠表达式。
  • 每个条件分支尽量做到“单一职责”,用 &&|| 连接时,可以用括号明确优先级。

七、文章总结

GitHub Actions表达式看起来简单,但字符串比较和数组处理背后的类型转换规则,足以让一个熟练的开发者栽跟头。核心的应对思路是:永远明确类型,不要依赖隐式转换;比较字符串时带上引号;检查数组时使用 containsempty,而不是直接拿数组本身当条件。记住,表达式设计出来是为了让门槛更低,但它并不是万能的。当逻辑变得复杂,及时切换到真正的编程语言,才是对自己和队友的温柔。下次再遇到 if 条件诡异不成立的时候,不妨先检查一下是不是又踩了类型转换的坑。