GitHub Actions 可复用工作流设计模式:把 CI/CD 重复逻辑收起来

前言

我最早写 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 被跳过,而后面的 buildneeds: 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 这种流程级控制。需要 permissionsenvironmentneeds、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-idtenant-idsubscription-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.ymldeploy.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 抽象做得好,团队会少很多复制粘贴,也少很多每个仓库各改一遍的维护成本。抽象做得过头,反而会让排查变难。真正适合长期维护的方案,一定是能让调用方看懂、能让共享仓库测试、能让生产部署有清楚边界的方案。

相关推荐
洛星核1 小时前
CrewAI 安装、使用方法详细全解
人工智能·github·人机交互·ai编程·agi·智能体
w3296362712 小时前
八、OpenCode 高阶玩法:CLI 自动化、CI/CD 集成与远程协作
运维·ci/cd·自动化·ai编程·开发工具·opencode
brycegao2 小时前
如何搭建标准化 Git 工具流,保障 Android 团队代码质量
android·ci/cd
火山上的企鹅3 小时前
Codex实战:APP远程升级服务搭建(四)Node 服务端自动识别 APK 信息
android·服务器·git·github·qgc
磊 子3 小时前
C++设计模式
javascript·c++·设计模式
Dontla5 小时前
gh CLI(GitHub CLI)安装教程(Github Command Line)
github
Dontla5 小时前
CI/CD前世今生(持续集成、持续交付、持续部署、Jenkins、Github Actions)
ci/cd·github·jenkins
委婉待续6 小时前
登录github出现ERR_CONNECTION_TIMED_OUT问题
github
洛星核6 小时前
Aider 安装、使用方法详细全解
人工智能·github·人机交互·ai编程·agi
法欧特斯卡雷特6 小时前
从 Kotlin 编译器 API 的变化开始: 2.4.0
android·开源·github