前言
我最早写 GitHub Actions 的时候,通常是从一个仓库里复制一份 .github/workflows/ci.yml,改一下项目名、Node 版本、构建命令和部署地址,就算接入完成。这个做法在项目少的时候没什么问题,真正麻烦的是项目多起来以后。
一个 Node 项目要跑 lint、test、build,另一个 Node 项目也要跑同样的流程。前端项目要上传构建产物,后端项目要跑镜像构建,几个仓库里的 YAML 越写越像。后来要升级 actions/setup-node,要统一 Node 版本,要给所有部署流程加 OIDC,要调整缓存策略,就会发现每个仓库都得改一遍。
GitHub Actions 本身已经提供了几种复用能力。可复用工作流负责把一套 job 编排抽出来,复合 Action 负责把多步操作收成一个 action,OIDC 和 environment 则负责把部署权限和密钥边界管住。它们经常被放在一起讲,但真实落地时不能混用。
我现在更习惯先判断一个问题属于哪一层:这是完整 CI 流程,还是几个步骤的封装;这是跨仓库统一规范,还是单仓库内部减少重复;这是构建逻辑复用,还是生产部署权限控制。边界先想清楚,Actions 的 YAML 才不会越抽越乱。

一、先区分三种复用边界
GitHub Actions 的复用能力看起来很多,实际可以先拆成三个层级。
| 机制 | 抽象层级 | 适合放什么 | 不适合放什么 |
|---|---|---|---|
| 可复用工作流 | workflow / job 编排 | CI、发布、部署、扫描、跨仓库统一流程 | 细碎脚本步骤 |
| 复合 Action | step 组合 | 安装工具、准备环境、发送通知、封装重复命令 | 复杂 job 依赖和环境审批 |
| 普通 workflow | 当前仓库入口 | 触发条件、项目参数、环境选择、权限声明 | 大段复制来的通用逻辑 |
这个表比直接写代码更重要。很多团队的 Actions 难维护,不是因为 YAML 写错,而是抽象层级一开始就混乱。
比如 Node 项目的 lint、test、build 流程,通常适合放进可复用工作流。调用方只传 Node 版本、工作目录、是否上传 artifact 这些参数。
比如安装内部 CLI、读取 package version、生成 changelog、发送 Slack 通知,这类连续步骤更适合做成复合 Action。它不关心上层 workflow 怎么触发,也不关心后面还有哪些 job。
部署到生产环境这件事,我不会只放进一个复合 Action 里。它涉及 environment、审批、OIDC、权限、云资源范围,应该放在 workflow 或 reusable workflow 的 job 编排里处理。复合 Action 只承担其中一段明确操作,比如登录后执行部署命令。
我会用一句工程判断来约束自己:workflow 管流程,action 管步骤,environment 管生产边界。
二、可复用工作流适合统一流程
可复用工作流通过 workflow_call 暴露给其他 workflow 调用。它最适合承接一整套流程,比如 Node CI、Docker 构建、安全扫描、部署到测试环境、发布 npm 包。
一个可复用工作流必须放在 .github/workflows 目录下。它和普通 workflow 很像,只是触发方式里包含 workflow_call。这个触发器可以定义 inputs、secrets 和 outputs,调用方通过 jobs.<job_id>.uses 引用它。
下面是一份比较克制的 Node CI 可复用工作流。
yaml
# .github/workflows/node-ci-reusable.yml
name: Reusable Node CI
on:
workflow_call:
inputs:
node-version:
description: Node.js version
required: false
type: string
default: '20'
working-directory:
description: Project working directory
required: false
type: string
default: '.'
run-tests:
description: Whether to run tests
required: false
type: boolean
default: true
upload-dist:
description: Whether to upload dist artifact
required: false
type: boolean
default: false
outputs:
artifact-name:
description: Uploaded artifact name
value: ${{ jobs.build.outputs.artifact-name }}
jobs:
build:
name: Lint, test and build
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ inputs.working-directory }}
outputs:
artifact-name: ${{ steps.artifact-meta.outputs.name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run lint
run: npm run lint
- name: Run tests
if: inputs.run-tests
run: npm test
- name: Build
run: npm run build
- name: Prepare artifact metadata
id: artifact-meta
run: echo "name=dist-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Upload dist artifact
if: inputs.upload-dist
uses: actions/upload-artifact@v4
with:
name: ${{ steps.artifact-meta.outputs.name }}
path: ${{ inputs.working-directory }}/dist
这段 workflow 我特意没有把测试拆成一个单独 job。原因很实际:如果 test job 通过 if: inputs.run-tests 被跳过,而后面的 build 又 needs: test,很多人会遇到 build 也被跳过的情况。刚开始做可复用工作流时,少一点 job 编排反而更稳。等项目确实需要并行测试和构建,再把 job 拆开。
调用方就很薄。
yaml
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
web:
uses: ./.github/workflows/node-ci-reusable.yml
with:
node-version: '20'
working-directory: apps/web
run-tests: true
upload-dist: true
api:
uses: ./.github/workflows/node-ci-reusable.yml
with:
node-version: '20'
working-directory: apps/api
run-tests: true
upload-dist: false
如果可复用工作流放在同一个仓库,可以用 ./.github/workflows/xxx.yml。如果放在单独的共享仓库,要写成:
yaml
jobs:
ci:
uses: my-org/actions-workflows/.github/workflows/node-ci-reusable.yml@v1
with:
node-version: '20'
跨仓库引用时,我会尽量使用 tag 或 commit SHA,不会直接引用 @main。@main 方便调试,但生产仓库不应该因为共享仓库一次未验证的提交,导致一批项目 CI 同时失败。
三、参数和密钥不要混在一起
可复用工作流最容易写乱的地方,是 inputs、secrets、variables 和 environment 之间的关系。
我会按这个规则拆:
| 类型 | 放什么 | 示例 |
|---|---|---|
| inputs | 非敏感配置 | Node 版本、工作目录、是否上传 artifact |
| secrets | 敏感值 | NPM token、云平台配置、Webhook |
| variables | 非敏感环境值 | App name、region、资源组名称 |
| environment | 部署阶段和审批边界 | staging、production |
调用方传 inputs 时使用 with,传 secrets 时使用 secrets。
yaml
jobs:
publish:
uses: my-org/actions-workflows/.github/workflows/npm-publish.yml@v1
with:
node-version: '20'
package-directory: packages/ui
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
同一组织或企业内部,也可以使用 secrets: inherit。这个写法省事,但我不会随便用。它会把调用方能访问的 secrets 交给被调用 workflow,边界会变宽。共享 workflow 如果只是发布 npm,就显式传 NPM_TOKEN;如果只是部署 Azure,就显式传 Azure 相关配置。能点名传,就不要整包继承。
还有一个细节很容易踩坑:environment secrets 不能像普通 secrets 那样通过 workflow_call 从调用方传给可复用工作流。可复用工作流里的 job 如果自己声明了 environment,它会使用被调用 workflow 所在仓库环境里的 secrets,而不是调用方通过 workflow_call 传进来的 environment secrets。
生产部署场景下,我更推荐让调用方 workflow 明确声明 environment,把部署 job 或部署 reusable workflow 的边界设计清楚。不要在共享 workflow 里偷偷决定生产环境。
四、复合 Action 适合封装步骤
复合 Action 不是小一号的可复用工作流。它更像一个可复用 step,把多条命令和多个 action 包成一个 action。调用方仍然在自己的 workflow 里决定 runner、permissions、environment、needs 和并发控制。
比如很多项目都要读取 package.json 版本号、生成构建元信息、写入 $GITHUB_OUTPUT。这类逻辑不值得每个 workflow 复制一遍,可以做成复合 Action。
yaml
# .github/actions/read-package-meta/action.yml
name: Read package metadata
description: Read name and version from package.json
inputs:
working-directory:
description: Directory that contains package.json
required: false
default: '.'
outputs:
package-name:
description: Package name
value: ${{ steps.meta.outputs.package-name }}
package-version:
description: Package version
value: ${{ steps.meta.outputs.package-version }}
runs:
using: composite
steps:
- name: Read package metadata
id: meta
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
echo "package-name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
echo "package-version=${PACKAGE_VERSION}" >> "$GITHUB_OUTPUT"
调用时很清楚:
yaml
steps:
- uses: actions/checkout@v4
- name: Read package metadata
id: package
uses: ./.github/actions/read-package-meta
with:
working-directory: packages/ui
- name: Print package metadata
run: |
echo "name=${{ steps.package.outputs.package-name }}"
echo "version=${{ steps.package.outputs.package-version }}"
复合 Action 的好处是简单、直观、可放在仓库里一起维护。它也有边界。它不能像 reusable workflow 那样声明完整的 job 编排,也不适合处理 deployment environment 这种流程级控制。需要 permissions、environment、needs、matrix、concurrency 的地方,通常留在 workflow 层。
我会用一个简单判断来选择:
| 你想复用的是 | 更适合 |
|---|---|
| 几个连续步骤 | 复合 Action |
| 一整套 CI 流程 | 可复用工作流 |
| 一个跨仓库统一部署规范 | 可复用工作流 |
| 一个命令行工具的安装和初始化 | 复合 Action |
| 一个带审批和云登录的生产部署 job | 可复用工作流 |
| 一个项目自己的触发条件和分支规则 | 普通 workflow |
如果抽象以后调用方还要传十几个参数,说明这个复用单元可能太大了。可复用不是把所有逻辑藏起来,而是让重复部分有稳定边界。
五、OIDC 要放进部署模板里
很多老的 GitHub Actions 部署流程,会把云平台长期凭证存到 GitHub Secrets 里。比如 Azure Service Principal Secret、AWS Access Key、GCP Service Account JSON。这些凭证能用,但风险也很直接:一旦泄露,它们通常有较长有效期,还需要人工轮换。
OIDC 的思路更适合 CI/CD。GitHub Actions job 在运行时向 GitHub OIDC Provider 申请一个身份令牌,云平台验证这个令牌里的仓库、分支、environment 等声明后,再发放短期访问令牌。GitHub 里不需要保存云平台长期 secret。
以 Azure 为例,workflow 里至少要给 job 配置 id-token: write,再用 azure/login 交换访问令牌。
yaml
name: Deploy to Azure
on:
workflow_dispatch:
push:
branches:
- main
permissions:
contents: read
id-token: write
jobs:
deploy:
name: Deploy production
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Azure login with OIDC
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Deploy web app
uses: azure/webapps-deploy@v3
with:
app-name: ${{ vars.AZURE_WEBAPP_NAME }}
package: ./dist
这里我把 client-id、tenant-id、subscription-id 放在 vars 里,而不是 secrets 里。它们本身不是云平台长期凭证。真正的信任边界在 Azure 侧的 federated credential 条件里,比如只允许某个 organization、repository、branch 或 environment 申请令牌。
这件事不能只看 GitHub workflow。云端也要配好条件。比如只允许:
text
repo:my-org/my-repo:environment:production
或者只允许 main 分支。条件越宽,OIDC 的安全收益越容易被抵消。比如允许整个组织任意仓库申请部署令牌,就不够克制。
我会把 OIDC 登录放进共享部署 workflow,而不是让每个业务仓库自己复制。这样能统一权限最小化、统一 environment、统一云登录方式,也方便以后切换云平台策略。
六、environment 管生产环境入口
可复用工作流解决重复,OIDC 解决长期凭证,environment 解决部署入口。生产部署不能只靠 branch 判断,至少要让生产 job 显式引用 environment: production。
yaml
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
这样 job 会进入 GitHub environment 的保护规则。生产环境可以配置 required reviewers、wait timer、deployment branches、environment secrets 和 environment variables。通过审批后,job 才能访问对应 environment secrets。
我更喜欢把 staging 和 production 拆成两个 environment,而不是用一个 DEPLOY_ENV 变量到处判断。
yaml
jobs:
deploy-staging:
if: github.ref == 'refs/heads/develop'
uses: ./.github/workflows/deploy-reusable.yml
with:
environment-name: staging
deploy-production:
if: github.ref == 'refs/heads/main'
uses: ./.github/workflows/deploy-reusable.yml
with:
environment-name: production
如果共享部署 workflow 需要接收环境名,可以这样设计:
yaml
# .github/workflows/deploy-reusable.yml
name: Reusable Deploy
on:
workflow_call:
inputs:
environment-name:
required: true
type: string
artifact-name:
required: true
type: string
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment-name }}
steps:
- uses: actions/download-artifact@v4
with:
name: ${{ inputs.artifact-name }}
- name: Azure login with OIDC
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Deploy
run: ./scripts/deploy.sh
这里有一个重要取舍。environment-name 作为 input 提高了复用性,但也要限制调用方能传哪些值。生产仓库里可以通过触发条件、branch protection 和 environment required reviewers 一起控制,避免随便一个分支传 production 就触发生产部署。
environment 的价值不在于让 YAML 更好看,而是让生产环境有一个明确的审批入口和密钥边界。部署失败可以重跑,凭证泄露和错误发布就麻烦得多。
七、共享仓库要按产品一样维护
很多团队会把可复用工作流放进一个 actions-workflows 或 .github 共享仓库。这个做法没问题,但共享仓库一旦被多个项目依赖,就不能再当成普通脚本仓库随便改。
我会给共享仓库定几条规则。
| 规则 | 原因 |
|---|---|
发布 tag,不让生产仓库引用 @main |
避免未验证改动影响所有项目 |
| 每个 reusable workflow 有示例调用方 | 方便使用和测试 |
| 给 inputs 写 description 和默认值 | 降低接入成本 |
用最小权限配置 permissions |
减少 token 权限扩大 |
| 给 breaking changes 发新 major tag | 避免旧项目被动升级 |
| 单独准备 sandbox 仓库测试 | 避免在业务仓库试错 |
| 保留 changelog | 让引用方知道升级影响 |
共享工作流和共享库一样,需要版本管理。比如 @v1 给稳定仓库用,@v1.2.0 给需要锁定版本的仓库用,@main 只给测试仓库用。每次修改共享 workflow,都应该先在 sandbox 里跑过。
命名也要克制。不要把 reusable workflow 命名成 build.yml、deploy.yml 这种太泛的名字。更好的名字是:
text
node-ci-reusable.yml
docker-build-reusable.yml
azure-webapp-deploy-reusable.yml
npm-publish-reusable.yml
调用方看到文件名就能判断它解决什么问题,不需要点进去读完整 YAML。
八、我会怎么从现有项目开始改
如果一个项目现在已经有很多重复 workflow,我不会一上来全部抽象。CI/CD 抽象最怕一次性改太大,最后每个仓库都跑不通。
我会按四步处理。
第一步,先找重复最明显、风险最低的流程。通常是 Node CI、Python test、Docker build 这类流程。它们输入少、权限低、失败影响小,适合先抽。
第二步,先在同一个仓库内部做 reusable workflow。比如把 .github/workflows/ci.yml 拆成入口 workflow 和 .github/workflows/node-ci-reusable.yml。同仓库跑通以后,再考虑放到共享仓库。
第三步,等调用方稳定以后,再抽复合 Action。比如 CI 里有一段反复出现的读取版本号、准备缓存、发送通知,再单独抽成 .github/actions/xxx。
第四步,再处理部署和 OIDC。部署最敏感,不要和 CI 抽象一起改。先保证构建和测试流程复用稳定,再把 OIDC、environment、审批、云平台权限慢慢收进去。
一个比较稳的改造顺序如下:
text
重复 CI 流程
↓
同仓库 reusable workflow
↓
共享仓库 reusable workflow
↓
常用步骤抽 composite action
↓
部署流程接入 OIDC
↓
production environment 加审批
这个顺序不追求一步到位。CI/CD 是基础设施,稳定比漂亮更重要。每次抽象都要能回滚,每个 reusable workflow 都要有最小可运行示例。
总结
GitHub Actions 的可复用能力真正要解决的,不是 YAML 写少几行,而是让团队的 CI/CD 逻辑有明确边界。可复用工作流适合统一流程,复合 Action 适合封装步骤,OIDC 适合去掉长期云凭证,environment 适合保护生产部署入口。
我会按这几个判断来落地:
- 跨仓库统一 CI,用 reusable workflow。
- 多个 workflow 里重复的几个 step,用 composite action。
- 云部署登录,优先用 OIDC。
- 生产发布,必须经过 environment。
- 共享 workflow 要打 tag,不要让业务仓库直接追
main。 - secrets 要显式传递,少用大范围继承。
- 部署权限要放在 cloud provider 的 federation 条件里一起控制。
Actions 抽象做得好,团队会少很多复制粘贴,也少很多每个仓库各改一遍的维护成本。抽象做得过头,反而会让排查变难。真正适合长期维护的方案,一定是能让调用方看懂、能让共享仓库测试、能让生产部署有清楚边界的方案。