一、问题初现:一条看似无害的条件判断
前几天我在折腾一个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表达式的字符串比较是区分大小写的,Main 和 main 完全是两个东西。我在一次代码评审里就看到有人因为分支名写成了 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里就能写条件判断。对于简单的逻辑,比如判断事件类型、分支、路径过滤,非常直观。而且它内置了不少函数,像 contains、startsWith、endsWith、toLowerCase 等,覆盖了日常需求。它还支持 &&、||、! 这些逻辑运算符,能够组合出比较丰富的条件。最关键的是,你不需要额外写代码,所有配置都在一个文件里完成,维护成本低。
5.2 缺点和雷区
它的类型转换规则实在不够直观。默认的宽松比较在很多情况下会突然改变结果,而且没有一个类似“严格模式”的开关。另外,调试比较困难。表达式的结果不会直接打印在日志上,一旦条件不满足,你只能靠猜。还有一点,数组与对象之间的转换规则在不同版本里可能会有细微差别,很容易产生“本地没问题、云端就挂”的现象。更麻烦的是,YAML解析器和表达式解析器会先后对字符串进行处理,你稍不注意写出的引号就会被吞掉,导致条件变成一团乱麻。
5.3 什么时候该用,什么时候该避开
如果条件逻辑只有两三个判断,用表达式完全没有问题。但如果你的判断逻辑超过五行,或者包含复杂的嵌套、循环、类型转换,那么我建议你别硬撑。可以把它抽成一个JavaScript action或一个Shell脚本,在里面用成熟的编程语言来处理,反而更可靠。表达式适合做“门卫”,不适合做“业务大脑”。换句话说,它用来挡掉不需要跑的任务很好用,但如果你需要在多个数组和对象之间做精细判断,还是交给代码更省心。
六、注意事项汇总
- 永远使用
==或!=,不要使用=或===。 - 在 YAML 中比较字符串时,尽量给两侧字符串加上双引号,避免被解析成布尔值。
- 整个表达式最好用
${{ ... }}包裹,并且在YAML里用单引号包裹整个值,防止特殊字符被转义。 - 数组比较时,明确
contains()会把两侧转成字符串再比较,不要依赖隐式转换来区分类型。 - 不要直接用数组或对象作为 if 条件,空数组和空对象在某些上下文中可能是“真值”,用
empty()函数或.size来判断。 - 分支名、标签名都区分大小写,统一用
toLowerCase()再比较才稳妥。 - 如果条件复杂,优先考虑写成外部 action 或脚本,而不是堆叠表达式。
- 每个条件分支尽量做到“单一职责”,用
&&和||连接时,可以用括号明确优先级。
七、文章总结
GitHub Actions表达式看起来简单,但字符串比较和数组处理背后的类型转换规则,足以让一个熟练的开发者栽跟头。核心的应对思路是:永远明确类型,不要依赖隐式转换;比较字符串时带上引号;检查数组时使用 contains 或 empty,而不是直接拿数组本身当条件。记住,表达式设计出来是为了让门槛更低,但它并不是万能的。当逻辑变得复杂,及时切换到真正的编程语言,才是对自己和队友的温柔。下次再遇到 if 条件诡异不成立的时候,不妨先检查一下是不是又踩了类型转换的坑。
评论
围绕“GitHub Actions 表达式里字符串比较和数组处理经常产生意外结果,熟悉上下文类型转换规则才能写出可靠条件,避免在 if 中埋雷”参与讨论