前言
我以前维护多个仓库的 GitHub Actions 时,最怕遇到一类需求:把所有项目的 CI 都升级一遍。
表面上只是把 Node 版本从 18 改到 20,或者把actions/cache的写法调整一下。真正动起来才发现,十几个仓库里的 workflow 长得差不多,但又不完全一样。有的多了安全扫描,有的多了 artifact 上传,有的部署前还要跑一段自定义脚本。每个仓库复制一份 YAML,短期看起来省事,时间长了就会变成维护负担。
GitHub Actions 的可调用工作流,也就是workflow_call,解决的是这一类重复。它允许把一段完整 workflow 抽出来,让其他 workflow 在 job 层级调用。这样一套 Node CI、一套安全扫描、一套镜像构建、一套部署流程,都可以沉淀在共享仓库里,业务仓库只保留触发条件和少量参数。
不过这类能力不能只看语法。真正落地时,最容易出问题的地方在边界:什么逻辑应该抽成可调用工作流,什么逻辑应该做成复合 Action,哪些 secrets 可以传,哪些 environment 不能从调用方直接传,跨仓库引用应该锁版本还是追main。这些问题想清楚以后,可调用工作流才会变成团队工程资产,而不是另一层看不懂的 YAML。
一、可调用工作流解决的是流程级复用
workflow_call是 GitHub Actions 的一种触发方式。一个 workflow 声明了workflow_call后,就可以被其他 workflow 调用。它和普通push、pull_request、workflow_dispatch不一样,不会因为代码推送自动运行,而是等待调用方通过uses明确引用。
可调用工作流的文件位置也有要求。它必须放在仓库根目录下的.github/workflows目录里,不能再往下放子目录。很多团队会自然想把共享流程整理成下面这种结构:
.github/workflows/ ci/ node.yml security/ dependency-scan.yml deploy/ production.yml这个结构看起来清楚,但 GitHub Actions 不支持这样的 workflow 子目录。更稳的做法是用文件名前缀表达分类:
.github/workflows/ ci-node-reusable.yml ci-python-reusable.yml security-dependency-scan-reusable.yml deploy-azure-webapp-reusable.yml调用方式也要注意。可调用工作流在 job 层级使用,不是在 step 层级使用。
jobs:node-ci:uses:my-org/shared-workflows/.github/workflows/ci-node-reusable.yml@v1with:node-version:'20'working-directory:apps/web如果写到 steps 里,那就变成调用 action 的语法了。这里的差异非常关键:复合 Action 是 step 级别复用,可调用工作流是 job 或 workflow 级别复用。
我会这样区分两者。
| 复用对象 | 适合机制 | 例子 |
|---|---|---|
| 一整套 CI 流程 | 可调用工作流 | 安装依赖、lint、test、build、上传 artifact |
| 一个完整部署 job | 可调用工作流 | 下载构建产物、OIDC 登录云平台、部署到环境 |
| 几个重复步骤 | 复合 Action | 读取 package 版本、安装内部 CLI、发送通知 |
| 某个独立工具动作 | 复合 Action | 格式化消息、执行脚本、生成变更摘要 |
| 当前仓库触发条件 | 普通 workflow | push、pull_request、workflow_dispatch |
这张表能避免很多后期维护问题。把完整 CI 做成复合 Action,会导致调用方还要自己管理 job、权限、缓存、artifact 和并发;把几个步骤硬塞进可调用工作流,又会让一个小动作变得过重。
二、从一个 Node CI 开始抽
抽可调用工作流时,我不建议一开始就抽生产部署。部署涉及权限、environment、审批和云平台登录,风险比较高。更合适的起点是 CI,例如 Node 项目的 lint、test、build。
下面这份 workflow 就可以作为共享 Node CI 的起点。
# .github/workflows/ci-node-reusable.ymlname:Reusable Node CIon:workflow_call:inputs:node-version:description:Node.js versionrequired:falsetype:stringdefault:'20'working-directory:description:Directory that contains package.jsonrequired:falsetype:stringdefault:'.'run-tests:description:Whether to run testsrequired:falsetype:booleandefault:trueupload-artifact:description:Whether to upload build outputrequired:falsetype:booleandefault:falseoutputs:artifact-name:description:Name of uploaded artifactvalue:${{jobs.node-ci.outputs.artifact-name}}jobs:node-ci:runs-on:ubuntu-latestdefaults:run:working-directory:${{inputs.working-directory}}outputs:artifact-name:${{steps.artifact-meta.outputs.artifact-name}}steps:-name:Checkout repositoryuses:actions/checkout@v4-name:Setup Node.jsuses:actions/setup-node@v4with:node-version:${{inputs.node-version}}cache:npmcache-dependency-path:${{inputs.working-directory}}/package-lock.json-name:Install dependenciesrun:npm ci-name:Run lintrun:npm run lint-name:Run testsif:inputs.run-testsrun:npm test-name:Buildrun:npm run build-name:Prepare artifact nameid:artifact-metarun:echo "artifact-name=dist-${GITHUB_SHA}">>"$GITHUB_OUTPUT"-name:Upload artifactif:inputs.upload-artifactuses:actions/upload-artifact@v4with:name:${{steps.artifact-meta.outputs.artifact-name}}path:${{inputs.working-directory}}/dist这份示例里,我把 lint、test、build 放在同一个 job 里。它不一定是性能最高的写法,但作为可调用工作流的第一版更容易维护。很多团队刚开始抽象时,会把 lint、test、build 拆成多个 job,再用 inputs 控制开关,最后遇到 job 被跳过以后下游needs也被影响的问题。
第一版先保证流程稳定,再考虑并行优化。共享 workflow 一旦被多个仓库引用,稳定性比 YAML 是否足够漂亮更重要。
业务仓库里的调用方会很薄。
# .github/workflows/ci.ymlname:CIon:pull_request:push:branches:-mainpermissions:contents:readjobs:web:uses:my-org/shared-workflows/.github/workflows/ci-node-reusable.yml@v1with:node-version:'20'working-directory:apps/webrun-tests:trueupload-artifact:trueapi:uses:my-org/shared-workflows/.github/workflows/ci-node-reusable.yml@v1with:node-version:'20'working-directory:apps/apirun-tests:trueupload-artifact:false调用方只保留触发条件、权限和项目参数。共享仓库负责 CI 细节。后面要升级 Node 版本、缓存策略、artifact 命名规则,都可以在共享 workflow 里统一处理。
三、inputs、secrets 和 outputs 要分清
workflow_call的参数化能力很强,但也容易滥用。一个可调用工作流如果有十几个输入参数,调用方会很痛苦;如果所有 secrets 都通过inherit传进去,安全边界又会变宽。
我会先按用途拆。
| 类型 | 放什么 | 示例 |
|---|---|---|
| inputs | 非敏感配置 | Node 版本、工作目录、是否上传产物、环境名 |
| secrets | 敏感值 | NPM token、Webhook、云平台私密配置 |
| outputs | 下游需要的数据 | artifact 名称、镜像 tag、版本号、部署地址 |
| variables | 非敏感环境信息 | 应用名、region、资源组、订阅 ID |
| environment | 审批和部署边界 | staging、production |
secrets 可以显式传递。
jobs:publish:uses:my-org/shared-workflows/.github/workflows/npm-publish-reusable.yml@v1with:package-directory:packages/uisecrets:NPM_TOKEN:${{secrets.NPM_TOKEN}}同一个 organization 或 enterprise 里,也可以使用:
secrets:inherit这个写法很省事,但我不会作为默认选择。它会把调用方当前可用的 secrets 传给被调用工作流。共享 workflow 如果只需要一个NPM_TOKEN,就只传这个 token;如果只需要 Slack Webhook,就只传 Webhook。能显式传递,就不要整包继承。
outputs 的写法也要留意三层映射。step 先写$GITHUB_OUTPUT,job 再暴露 step 输出,workflow 再暴露 job 输出。
# .github/workflows/build-image-reusable.ymlname:Reusable Docker Buildon:workflow_call:inputs:image-name:required:truetype:stringoutputs:image-tag:description:Docker image tagvalue:${{jobs.build.outputs.image-tag}}jobs:build:runs-on:ubuntu-latestoutputs:image-tag:${{steps.meta.outputs.image-tag}}steps:-uses:actions/checkout@v4-name:Generate image tagid:metarun:echo "image-tag=${{inputs.image-name}}:${GITHUB_SHA}">>"$GITHUB_OUTPUT"-name:Build imagerun:docker build-t "${{steps.meta.outputs.image-tag}}" .调用方通过needs.<job_id>.outputs.<name>读取:
jobs:build:uses:my-org/shared-workflows/.github/workflows/build-image-reusable.yml@v1with:image-name:my-appdeploy:runs-on:ubuntu-latestneeds:buildsteps:-name:Print image tagrun:echo "${{needs.build.outputs.image-tag}}"这里的 job id 是调用方自己的build,不是被调用工作流内部的 job id。这个细节刚开始很容易搞混。
四、矩阵可以配合可调用工作流,但别一上来玩复杂
可调用工作流可以和 matrix 配合。比如同一套 Node CI,要在多个包、多个 Node 版本里跑。
name:Monorepo CIon:pull_request:permissions:contents:readjobs:package-ci:strategy:fail-fast:falsematrix:include:-package:apps/webnode-version:'20'-package:apps/adminnode-version:'20'-package:packages/uinode-version:'20'uses:my-org/shared-workflows/.github/workflows/ci-node-reusable.yml@v1with:node-version:${{matrix.node-version}}working-directory:${{matrix.package}}run-tests:trueupload-artifact:false这个写法很适合 monorepo。每个包都走同一套 CI,参数从 matrix 里传进去。共享 workflow 保持统一,调用方只维护包列表。
不过我不建议一开始就在 reusable workflow 里面再套很复杂的 matrix。调用方有 matrix,被调用 workflow 内部也有 matrix,再加 outputs,很快就会变得难排查。尤其是 matrix workflow outputs,有多个成功任务都设置输出时,最终取值会受完成顺序和输出规则影响,不适合承接关键部署数据。
我的处理习惯是:
- matrix 放在调用方时,用来控制多个项目、多个版本、多个环境。
- reusable workflow 内部保持相对简单。
- 关键输出尽量不要依赖多个 matrix job 汇总。
- 如果确实需要汇总结果,单独增加一个汇总 job。
CI 可以并行,部署要克制。尤其是 production,不要轻易用 matrix 一次性并行推多个环境。
五、嵌套调用要少用
GitHub Actions 支持可调用工作流嵌套,但这不代表应该频繁嵌套。当前限制是最多四层,也就是顶层调用 workflow,再往下最多三层可复用工作流。
比如:
caller workflow → reusable workflow A → reusable workflow B → reusable workflow C再往下就不合适了。即使平台允许,排查也会非常痛苦。一个 CI 失败,你要从业务仓库跳到共享 workflow A,再跳到 B,再跳到 C,看每一层的 inputs、secrets、permissions 和 outputs。团队里不是每个人都愿意这样查。
权限也不能在下游工作流里扩大。上层 workflow 给了contents: read,下层不能凭空拿到contents: write。secrets 也只会传给直接调用的下一层,如果 A 调用 B,B 再调用 C,C 只有在 B 显式传递后才能拿到对应 secret。
我会给团队定一个很简单的规则:可调用工作流可以嵌套,但默认不要超过两层。
比较合理的结构是:
业务仓库 workflow → 共享 CI workflow或者:
业务仓库 workflow → 共享部署 workflow → 共享通知 workflow如果需要第三层,通常说明共享 workflow 设计得太碎,或者复合 Action 更合适。
六、共享工作流仓库不要用子目录放 workflow
团队做共享 workflow 仓库时,经常会自然想按功能建目录:
shared-workflows/ .github/ workflows/ ci/ node.yml security/ dependency-scan.yml deploy/ k8s.yml这个结构在 GitHub Actions 里不能直接作为 workflow 使用。.github/workflows下面必须是 workflow 文件,不能通过子目录引用。
我会改成这种结构:
shared-workflows/ .github/ workflows/ ci-node-reusable.yml ci-python-reusable.yml security-dependency-scan-reusable.yml security-codeql-reusable.yml deploy-k8s-reusable.yml deploy-azure-webapp-reusable.yml docs/ ci-node.md deploy-k8s.md examples/ node-service-ci.yml k8s-deploy.ymlworkflow 文件放在.github/workflows顶层,文档和示例放到docs、examples里。这样既符合平台要求,也能保持仓库可读性。
共享仓库还要像产品一样维护。
| 维护项 | 建议 |
|---|---|
| 版本 | 用v1、v2tag 或 release 分支 |
| 示例 | 每个 workflow 至少有一份最小调用示例 |
| 文档 | 写清 inputs、secrets、outputs、权限要求 |
| 测试 | 准备 sandbox 仓库跑真实调用 |
| 审查 | 修改共享 workflow 必须走 PR |
| 变更日志 | breaking change 单独记录 |
| 引用策略 | 业务仓库不要直接引用@main |
共享 workflow 的影响范围比普通业务代码更大。一个没测试过的 YAML 改动,可能会让多个仓库同一天 CI 全挂。共享仓库越核心,越要有版本和回滚策略。
七、权限和安全要放在设计里
可调用工作流一旦跨仓库使用,权限就不能只靠默认值。workflow 里的permissions要尽量写清楚。
CI 里大多数时候只需要:
permissions:contents:read如果要上传包、创建 release、写 PR 评论、推送镜像,再按需增加权限。不要为了省事直接给write-all。
部署类 reusable workflow 要更谨慎。生产部署通常会同时涉及:
id-token: write,用于 OIDC。contents: read,用于 checkout。- environment,触发生产审批和环境 secrets。
- 云平台 federated credential,限制仓库、分支和环境。
比如:
name:Reusable Production Deployon:workflow_call:inputs:environment-name:required:truetype:stringartifact-name:required:truetype:stringpermissions:contents:readid-token:writejobs:deploy:runs-on:ubuntu-latestenvironment:${{inputs.environment-name}}steps:-name:Download artifactuses:actions/download-artifact@v4with:name:${{inputs.artifact-name}}-name:Cloud login with OIDCrun:./scripts/cloud-login.sh-name:Deployrun:./scripts/deploy.sh这个示例还不完整,但能说明一件事:部署边界要放在 workflow 层。复合 Action 里可以封装登录命令或部署脚本,但 production environment、permissions、OIDC 这种东西,应该由 job 级别显式声明。
还有一点要特别留意。可调用工作流如果使用environment,environment secrets 的行为和普通 secrets 不一样。调用方不能通过workflow_call把 environment secrets 直接传进去。生产部署里,我会让调用方和被调用方的 environment 设计保持非常明确,不让 secrets 在多层 workflow 里绕来绕去。
八、迁移时不要一次性全改
如果团队已经有很多仓库,每个仓库都有自己的 CI/CD,迁移到可调用工作流时不要一次性全部替换。这样做出问题时,很难判断是共享 workflow 的 bug,还是某个仓库自己的差异。
我会按四步来做。
第一步,收集现有 workflow,找重复度最高的部分。通常最先抽的是 lint、test、build。
第二步,选一个低风险仓库试点。不要选最复杂、最核心的生产项目。先找一个中等复杂度项目,验证 reusable workflow 的 inputs 是否够用。
第三步,把旧 workflow 和新 workflow 并行跑一段时间。比如新 workflow 先只在workflow_dispatch或测试分支上跑,确认结果一致后再替换主流程。
第四步,逐步扩大到更多仓库。每迁一个仓库,就记录它额外需要的参数和例外情况。如果例外越来越多,说明共享 workflow 抽得太早或者抽象边界不对。
迁移过程中,我会特别关注这些问题:
| 检查项 | 为什么要看 |
|---|---|
| CI 耗时有没有明显变化 | 共享 workflow 可能引入额外步骤 |
| 缓存是否命中 | 工作目录和 cache-dependency-path 容易配置错 |
| secrets 是否传得太宽 | inherit容易扩大权限 |
| artifact 名称是否稳定 | 下游 job 依赖输出时容易出问题 |
| 权限是否最小化 | 默认权限可能不符合安全要求 |
| 失败日志是否可读 | 共享 workflow 过度封装会影响排查 |
| 调用方是否容易理解 | 业务仓库维护者要能看懂参数含义 |
可调用工作流不是为了让 YAML 消失。业务仓库里仍然应该能看出这条 CI 做了什么、用了哪个共享版本、传了哪些参数。抽象的目标是减少重复,不是制造黑盒。
总结
GitHub Actions 可调用工作流最适合解决跨仓库重复流程。它通过workflow_call把完整 job 编排抽出来,让业务仓库用少量参数调用共享流程。用得好,团队可以统一 CI、测试、安全扫描和部署模板;用得太随意,也会带来版本、权限、secrets 和排查成本。
我会按这几个原则落地:
- 完整流程用可调用工作流,几个步骤用复合 Action。
- 可调用工作流文件直接放在
.github/workflows下,不放子目录。 - 跨仓库引用使用稳定 tag 或 commit SHA,不直接追
@main。 - inputs 放非敏感参数,secrets 显式传递,少用整包继承。
- 嵌套调用保持克制,默认不要超过两层。
- 生产部署要结合 environment、OIDC 和最小权限。
- 共享 workflow 仓库要有文档、示例、测试和版本管理。
- 迁移时从低风险 CI 开始,不要直接抽生产部署。
真正成熟的可调用工作流,应该让调用方更轻,也让维护者更清楚。它不是把复杂度藏起来,而是把重复流程放到一个可以统一审查、统一升级、统一回滚的位置。