GitHub Actions Reusable Workflow:零配置统一 CI/CD 的完整实现

GitHub Actions Reusable Workflow:零配置统一 CI/CD 的完整实现

本文是《统一 CI/CD 流水线治理》系列第三篇。本文深入拆解平台团队如何用 Reusable Workflow 实现”业务仓库零配置接入”的完整方案,涵盖架构设计、JWT/OIDC 认证、多环境路由、容器构建以及踩坑记录。本文来自一个覆盖 500+ 仓库、生产运行中的实践。


第一部分:架构概述

.github 仓库作为平台边界

在 GitHub Organization 中,名为 .github 的特殊仓库承担两类职责:一是存放 Organization 级别的默认 Community Health Files(CODE_OF_CONDUCT.md 等),二是存放 Reusable Workflow 文件供 Org 内所有仓库调用。

平台团队将所有 CI/CD 逻辑集中在 .github 仓库,形成清晰的平台边界:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
OrgA/.github
├── .github/
│ └── workflows/
│ ├── platform-ci-core.yml # 编排器 / 入口
│ ├── platform-ci-check.yml # lint + 单元测试
│ ├── platform-ci-security.yml # 静态代码扫描
│ ├── platform-ci-build.yml # 容器构建 + 多仓库推送 + 签名
│ ├── platform-ci-prepare-release.yml
│ ├── platform-ci-release.yml
│ └── platform-ci-deploy.yml
└── actions/
├── prepare-release/
├── release/
└── security-scan/

500+ 个业务仓库全部调用这同一套 workflow 文件。每个业务仓库只需维护一个极简的 ci.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# .github/workflows/ci.yml(业务仓库,约 15 行)
name: CI

on:
push:
branches: [trunk, releases/latest]
pull_request:
branches: [trunk, releases/latest]
pull_request_target:
branches: [trunk, releases/latest]

jobs:
pipeline:
# fork PR 用 pull_request_target,同 repo PR 用 pull_request
if: >-
github.event_name != 'pull_request_target' ||
github.event.pull_request.head.repo.full_name != github.repository
uses: OrgA/.github/.github/workflows/platform-ci-core.yml@main
# 无 with: 块 —— 所有配置从 .ci-config/config.yaml 读取
# 无 secrets: 块 —— 凭证由平台流水线内部处理

pull_request_target 的必要性

pull_request 事件在 fork PR 场景下无法访问 Org Secrets,导致需要 Vault 凭证的 job 全部失败。改用 pull_request_target 后,workflow 在 base 仓库的上下文中运行,可以访问 Secrets。但这带来了安全隐患——详见踩坑经验章节。


Workflow 文件职责说明

文件职责
platform-ci-core.yml编排器,包含 config job,调用所有下游 reusable workflow
platform-ci-check.ymlpylint、shellcheck、单元测试
platform-ci-security.ymlSonarQube / Semgrep 扫描
platform-ci-build.ymlbuildx 构建、多仓库推送、镜像签名
platform-ci-prepare-release.yml计算下一个语义化版本号
platform-ci-release.yml打 Git tag、创建 GitHub Release
platform-ci-deploy.yml状态上报、Docker 信息推送、健康检查

第二部分:config job — 替代 Groovy Merger 的关键设计

为什么需要专门的 config job

GitHub Actions 的 workflow 结构是静态的:with: 字段的值在 workflow 解析时就必须确定类型,if: 条件在 job 级别也有语法限制。平台无法像 Jenkins Groovy 那样在运行时动态合并配置。

解决方案是在编排器中设置一个 config job,专门读取业务仓库的 .ci-config/config.yaml,将所有派生配置作为 outputs 输出,供下游 job 通过 needs.config.outputs.* 消费。

1
2
3
4
5
6
7
8
9
.ci-config/config.yaml(业务仓库声明)


config job(解析 + 计算,一次运行)

├──► platform-ci-check.yml (python_version, pylint_sources)
├──► platform-ci-security.yml (sonar_project_key)
├──► platform-ci-build.yml (dockerfile, image_url_list)
└──► platform-ci-deploy.yml (content_name, image_url_list)

config job 的完整 shell 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# platform-ci-core.yml(config job 节选)
jobs:
config:
name: Resolve Config
runs-on: [self-hosted, linux]
outputs:
python_version: ${{ steps.resolve.outputs.python_version }}
pylint_module_paths: ${{ steps.resolve.outputs.pylint_module_paths }}
pylint_rc_file: ${{ steps.resolve.outputs.pylint_rc_file }}
has_unit_test: ${{ steps.resolve.outputs.has_unit_test }}
unit_test_script: ${{ steps.resolve.outputs.unit_test_script }}
dockerfile: ${{ steps.resolve.outputs.dockerfile }}
image_url_list: ${{ steps.resolve.outputs.image_url_list }}
sonar_project_key: ${{ steps.resolve.outputs.sonar_project_key }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Install yq
run: |
if ! command -v yq &>/dev/null; then
curl -fsSL https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \
-o /usr/local/bin/yq && chmod +x /usr/local/bin/yq
fi

- name: Parse .ci-config/config.yaml
id: resolve
env:
REPO_NAME: ${{ github.event.repository.name }}
REPO_OWNER: ${{ github.repository_owner }}
run: |
CFG=".ci-config/config.yaml"

# Python 版本:从镜像 tag 中提取语义化版本
# 例:platform-registry.example.com/python:3.14 → 3.14
RAW_IMAGE=$(yq '.containers[] | select(.name=="project-runtime") | .image' \
"${CFG}" 2>/dev/null || echo "")
PYTHON_VERSION=$(echo "${RAW_IMAGE}" | \
grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?$' || echo "3.x")
[ -z "${PYTHON_VERSION}" ] && PYTHON_VERSION="3.x"

# pylint sourceSets → 空格分隔字符串
PYLINT_PATHS=$(yq '.jobs[] | select(.name=="lint") | .steps[].pyLint.sourceSets[]' \
"${CFG}" 2>/dev/null | tr '\n' ' ' | xargs || echo "")

# pylint rcFile:过滤 yq 的 null 输出
PYLINT_RC=$(yq '.jobs[] | select(.name=="lint") | .steps[].pyLint.rcFile' \
"${CFG}" 2>/dev/null | grep -v '^null$' || echo "")

# 单元测试检测
UNIT_TEST_SCRIPT=$(yq '.jobs[] | select(.name=="unit-test") | .steps[].script.workspace' \
"${CFG}" 2>/dev/null | grep -v '^null$' | head -1 || echo "")
HAS_UNIT_TEST="false"
[ -n "${UNIT_TEST_SCRIPT}" ] && HAS_UNIT_TEST="true"

# containerBuild 配置
DOCKERFILE=$(yq '.containerBuild.path' "${CFG}" 2>/dev/null | grep -v '^null$' || echo "")
IMAGE_TYPE=$(yq '.containerBuild.registryType' "${CFG}" 2>/dev/null | \
grep -v '^null$' || echo "internet")

# 根据 image_type 计算目标 registry
case "${IMAGE_TYPE}" in
private) PRIMARY_REG="internal-private.platform-registry.example.com" ;;
public) PRIMARY_REG="internal-public.platform-registry.example.com" ;;
internet) PRIMARY_REG="internet.platform-registry.example.com" ;;
*) PRIMARY_REG="internal.platform-registry.example.com" ;;
esac
PRIMARY_URL="${PRIMARY_REG}/${REPO_OWNER}/${REPO_NAME}"

# internet 类型额外推送到公开仓库
if [ "${IMAGE_TYPE}" = "internet" ]; then
PUBLIC_URL="public.platform-registry.example.com/${REPO_OWNER}/${REPO_NAME}"
IMAGE_URL_LIST="${PRIMARY_URL},${PUBLIC_URL}"
else
IMAGE_URL_LIST="${PRIMARY_URL}"
fi
IMAGE_URL_LIST="${IMAGE_URL_LIST,,}" # 转小写

echo "python_version=${PYTHON_VERSION}" >> "$GITHUB_OUTPUT"
echo "pylint_module_paths=${PYLINT_PATHS}" >> "$GITHUB_OUTPUT"
echo "pylint_rc_file=${PYLINT_RC}" >> "$GITHUB_OUTPUT"
echo "has_unit_test=${HAS_UNIT_TEST}" >> "$GITHUB_OUTPUT"
echo "unit_test_script=${UNIT_TEST_SCRIPT}" >> "$GITHUB_OUTPUT"
echo "dockerfile=${DOCKERFILE}" >> "$GITHUB_OUTPUT"
echo "image_url_list=${IMAGE_URL_LIST}" >> "$GITHUB_OUTPUT"

# Job Summary:配置解析结果表格(调试友好,500+ 仓库的支持成本关键)
echo "### Config resolved from .ci-config/config.yaml" >> "$GITHUB_STEP_SUMMARY"
echo "| Key | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|-----|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| python_version | \`${PYTHON_VERSION}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| pylint_module_paths | \`${PYLINT_PATHS}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| has_unit_test | \`${HAS_UNIT_TEST}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| dockerfile | \`${DOCKERFILE:-<none>}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| image_url_list | \`${IMAGE_URL_LIST}\` |" >> "$GITHUB_STEP_SUMMARY"

Job Summary 的重要性在 500+ 规模下被放大:当业务团队反馈”CI 行为不符合预期”时,平台团队需要快速定位是 config.yaml 解析问题还是流水线逻辑问题。Step Summary 中的配置表格让这个诊断从”需要查日志”变成”打开 PR 页面就能看到”。

下游调用方式

1
2
3
4
5
6
7
8
build:
name: Build Container Image
needs: [config, security]
if: ${{ needs.config.outputs.dockerfile != '' }}
uses: OrgA/.github/.github/workflows/platform-ci-build.yml@main
with:
dockerfile: ${{ needs.config.outputs.dockerfile }}
image_url_list: ${{ needs.config.outputs.image_url_list }}

第三部分:JWT/OIDC 凭证架构深度解析

job_workflow_ref claim 的本质

GitHub Actions OIDC token 中有一个关键 claim:job_workflow_ref。它的值是被调用 workflow 文件的路径,而非调用方仓库名。

当业务仓库 OrgA/my-app(500 个仓库之一)调用 platform-ci-build.yml 时:

1
2
job_workflow_ref = "OrgA/.github/.github/workflows/platform-ci-build.yml@refs/heads/main"
repository = "OrgA/my-app" ← 这是业务仓库,不是 .github

Vault 的 bound_claims 绑定 job_workflow_ref——无论哪个业务仓库触发,只要调用的是平台 workflow 文件,就能通过认证。500 个仓库,1 个 Vault role,0 个静态凭证。

完整认证流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
业务仓库 ci.yml(500 个中的任意一个)
│ (uses: OrgA/.github/...platform-ci-build.yml@main)

platform-ci-build.yml(job 级别)
│ permissions:
│ id-token: write ← 必须在 sub-workflow job 级别声明

├─1─► GHE OIDC Endpoint(https://ghe.example.com/_services/token)
│ └── 返回 JWT,含 job_workflow_ref claim

├─2─► vault-action (method: jwt)
│ ├── POST /v1/auth/jwt/login
│ │ { jwt: <oidc_token>, role: "platform-ci" }
│ │
│ └── Vault 验证流程:
│ ├── 获取 OIDC Discovery Document(JWKS endpoint)
│ ├── 验证 JWT 签名
│ ├── 检查 bound_claims(job_workflow_ref glob 匹配)
│ ├── 检查 @refs/heads/main 后缀(branch lock)
│ └── 返回 batch token(TTL: 5min)

└─3─► 使用 batch token 读取 KV secrets
(Registry 凭证、代码签名证书等)

Vault Role 的 JSON 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"role_type": "jwt",
"bound_audiences": ["https://vault.example.com"],
"bound_claims_type": "glob",
"bound_claims": {
"job_workflow_ref": "OrgA/.github/.github/workflows/*@refs/heads/main"
},
"user_claim": "repository",
"claim_mappings": {
"repository": "repository",
"ref": "ref",
"workflow": "workflow",
"job_workflow_ref": "job_workflow_ref"
},
"policies": ["platform-ci"],
"ttl": "5m",
"max_ttl": "10m",
"token_type": "batch",
"token_no_default_policy": false
}

在 500+ 仓库规模下,这个设计的安全价值尤为显著:

  • 500 个业务仓库,无一存储任何凭证
  • 任何一个业务仓库被攻击,攻击者仍无法获取平台凭证(job_workflow_ref 不匹配)
  • 平台 workflow 文件的每次修改都必须经过 main 分支的 code review,@refs/heads/main 在 Vault 层强制执行

注意:Vault CLI 不支持通过 key=value 传递 map 类型参数(bound_claimsclaim_mappings)。必须使用 JSON heredoc stdin 格式:

1
2
3
vault write auth/jwt/role/platform-ci - <<'EOF'
{ ...full JSON... }
EOF

batch token 的三个关键属性

  1. 不可续期vault token renew 对 batch token 无效,TTL 到期即失效
  2. 不可查询:不出现在 vault list auth/token/accessors 中,500 个仓库每天产生的数千个 token 都不留可查询踪迹
  3. 5分钟 TTL:足够完成一次 vault-action 调用,过期后即使泄漏也无法使用

GHE 与 github.com 的 OIDC Issuer 差异

1
2
github.com:  https://token.actions.githubusercontent.com
GHE: https://your-ghe-hostname/_services/token

Vault 的 oidc_discovery_url 必须指向正确的 issuer,否则 Vault 无法获取正确的 JWKS endpoint 来验证签名:

1
2
3
4
# GHE 环境
vault write auth/jwt/config \
oidc_discovery_url="https://ghe.example.com/_services/token" \
bound_issuer="https://ghe.example.com/_services/token"

permissions: id-token: write 必须在每个 sub-workflow 的 job 级别声明

编排器 platform-ci-core.yml 中声明的 permissions 不会自动传递给通过 uses: 调用的 reusable workflow。每个需要获取 OIDC token 的 job 都必须独立声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# platform-ci-build.yml
jobs:
build:
runs-on: [self-hosted, linux]
permissions:
id-token: write # 必须在这里声明,不能依赖编排器
contents: read
steps:
- uses: hashicorp/vault-action@v3
with:
url: ${{ vars.VAULT_URL }}
namespace: ${{ vars.VAULT_NAMESPACE }}
method: jwt
role: ${{ vars.VAULT_ROLE }}
jwtGithubAudience: ${{ vars.VAULT_AUDIENCE }}
secrets: |
secret/data/platform/${{ vars.VAULT_ENV }}/registry username | REGISTRY_USER ;
secret/data/platform/${{ vars.VAULT_ENV }}/registry password | REGISTRY_PASS

第四部分:多环境路由——Org Variables 方案

设计动机

传统方案是在 workflow 中写 if/else 环境判断,导致 workflow 文件与环境强耦合。在 500+ 仓库规模下,任何一次环境配置变更都需要修改平台 workflow 文件并重新测试。

平台团队采用 Organization Variable 注入的方案:每个 Org 在创建时由平台脚本一次性写入环境相关变量,workflow 代码完全不包含环境分支逻辑,对三套环境(dev/stg/prod)使用完全相同的代码。

六个 Org Variables

VariableOrgA-DevOrgA-StgOrgA(prod)
VAULT_URLhttps://vault.example.com← 同← 同
VAULT_NAMESPACEplatform/devplatform/stgplatform/prod
VAULT_ROLEplatform-ci← 同← 同
VAULT_AUDIENCEhttps://vault.example.com← 同← 同
VAULT_ENVdevstgprod
API_URLhttps://api.platform-dev.example.comhttps://api.platform-stg.example.comhttps://api.platform.example.com

vault-actionsecrets: 字段中,${{ vars.VAULT_ENV }} 自动插值:

1
2
secrets: |
secret/data/platform/${{ vars.VAULT_ENV }}/registry username | REGISTRY_USER

OrgA-Dev 中 → secret/data/platform/dev/registry
OrgA 中 → secret/data/platform/prod/registry

workflow 代码一行不改,三个环境(覆盖 500+ 仓库)自动路由。

apply-org-variables.sh 幂等实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#!/usr/bin/env bash
# 幂等写入 Organization Variables
# 已存在 → PATCH(更新),不存在 → POST(创建)

set -euo pipefail

apply_org_vars() {
local ORG=$1
local ENV=$2

declare -A VARS=(
["VAULT_URL"]="https://vault.example.com"
["VAULT_NAMESPACE"]="platform/${ENV}"
["VAULT_ROLE"]="platform-ci"
["VAULT_AUDIENCE"]="https://vault.example.com"
["VAULT_ENV"]="${ENV}"
["API_URL"]="https://api.platform-${ENV}.example.com"
)

for KEY in "${!VARS[@]}"; do
VALUE="${VARS[$KEY]}"
HTTP_STATUS=$(gh api "orgs/${ORG}/actions/variables/${KEY}" \
-i --silent 2>&1 | head -1 | awk '{print $2}')

if [ "${HTTP_STATUS}" = "200" ]; then
gh api --method PATCH "orgs/${ORG}/actions/variables/${KEY}" \
-f value="${VALUE}" -f visibility="all" --silent
echo "[UPDATE] ${ORG} / ${KEY}=${VALUE}"
else
gh api --method POST "orgs/${ORG}/actions/variables" \
-f name="${KEY}" -f value="${VALUE}" -f visibility="all" --silent
echo "[CREATE] ${ORG} / ${KEY}=${VALUE}"
fi
done
}

apply_org_vars "OrgA-Dev" "dev"
apply_org_vars "OrgA-Stg" "stg"
apply_org_vars "OrgA" "prod"

第五部分:容器构建的工程细节

多仓库推送设计

image_url_list 是逗号分隔的 URL 列表:

1
2
3
4
5
6
internet 类型:
internet.platform-registry.example.com/OrgA/my-app
+ public.platform-registry.example.com/OrgA/my-app

private 类型:
internal-private.platform-registry.example.com/OrgA/my-app(仅此一个)

docker buildx imagetools create 跨仓库复制

构建完成后,使用 imagetools create 直接在 registry 层复制 manifest,不需要将镜像 pull 到 runner 本地,节省带宽和时间——在 500+ 仓库的高频构建场景下,这个优化累积效果显著:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 构建并推送到主仓库(primary)
docker buildx build \
--platform linux/amd64 \
--tag "${PRIMARY_URL}:${TAG}" \
--push \
--file "${DOCKERFILE}" .

# 跨仓库复制 manifest(仅 manifest,不经过 runner)
IFS=',' read -ra ALL_URLS <<< "${IMAGE_URL_LIST}"
PRIMARY_URL="${ALL_URLS[0]}"

for EXTRA_URL in "${ALL_URLS[@]}"; do
[ "${EXTRA_URL}" = "${PRIMARY_URL}" ] && continue

EXTRA_REGISTRY=$(echo "${EXTRA_URL}" | cut -d'/' -f1)
if echo "${EXTRA_REGISTRY}" | grep -q 'public\.platform'; then
echo "${PUBLIC_REGISTRY_PASS}" | docker login "${EXTRA_REGISTRY}" \
-u "${PUBLIC_REGISTRY_USER}" --password-stdin
else
echo "${REGISTRY_PASS}" | docker login "${EXTRA_REGISTRY}" \
-u "${REGISTRY_USER}" --password-stdin
fi

docker buildx imagetools create --tag "${EXTRA_URL}:${TAG}" "${PRIMARY_URL}:${TAG}"
echo "Pushed ${EXTRA_URL}:${TAG}"
done

镜像签名(Signify)

平台使用内部 Signify 服务进行镜像签名,采用 mTLS 客户端证书认证。所有 tag × 所有仓库都需要签名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
IFS=',' read -ra ALL_URLS <<< "${IMAGE_URL_LIST}"
IFS=',' read -ra ALL_TAGS <<< "${TAG_LIST}"

for URL in "${ALL_URLS[@]}"; do
for TAG in "${ALL_TAGS[@]}"; do
IMAGE="${URL}:${TAG}"
DIGEST=$(docker buildx imagetools inspect "${IMAGE}" \
--format '{{json .Manifest}}' | jq -r '.digest' | sed 's/^sha256://')
MANIFEST=$(docker manifest inspect "${IMAGE}" 2>/dev/null)
BYTE_SIZE=$(echo "${MANIFEST}" | jq -r '.config.size // 0')

GUN=$(echo "${IMAGE}" | rev | cut -d':' -f2- | rev)
PAYLOAD="{\"trustedCollections\":[{\"gun\":\"${GUN}\",\"targets\":[{\"name\":\"${TAG}\",\"digest\":\"${DIGEST}\",\"byteSize\":${BYTE_SIZE}}]}]}"

curl -sf -X POST \
--cert "${CERT_FILE}" \
--key "${KEY_FILE}" \
--pass "${KEY_PASS}" \
"${SIGNIFY_ENDPOINT}/trusted-collections/publish" \
-H "Content-Type: application/json" \
-d "${PAYLOAD}" || echo "Warning: signing failed for ${IMAGE}, continuing"
done
done

GHE 上 upload-artifact@v4 的兼容问题

GitHub Enterprise Server 某些版本不支持 actions/upload-artifact@v4 使用的新 API:

1
2
Error: GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+ and
download-artifact@v4+ are not currently supported on GHES.

在 500+ 仓库规模下,这类兼容性问题的影响面是全量的——必须降级至 v3:

1
2
3
4
5
6
7
- uses: actions/upload-artifact@v3
with:
name: lint-report
path: reports/
- uses: actions/download-artifact@v3
with:
name: lint-report

第六部分:per-repo Secrets — Vault Enterprise Secrets Sync

两类凭证的分工

凭证类型获取方式示例安全级别
平台共享凭证JWT/OIDC 运行时从 Vault 拉取Registry 凭证、签名证书高(跨 Org 权限)
业务仓库专属凭证Vault Secrets Sync 推送为 GitHub Secret数据库连接串、业务 API key中(单仓库权限)

在 500+ 仓库规模下,per-repo Secrets 的管理需要自动化——不能手动为 500 个仓库配置。Vault Enterprise Secrets Sync 提供了这个能力。

Vault Enterprise Secrets Sync 配置

1
2
3
4
5
6
7
8
9
10
11
# 1. 创建 GitHub Actions 同步目标(Fine-Grained PAT,仅需 secrets:write)
vault write sys/sync/destinations/github-actions/my-app-prod \
access_token="github_pat_xxxx" \
repository_owner="OrgA" \
repository_name="my-app" \
secret_name_template="{{.SecretKey | uppercase}}"

# 2. 创建 Association:KV 路径 → GitHub Actions Secret
vault write sys/sync/associations/my-app-prod \
mount="secret" \
secret_name="apps/my-app/prod/db"

轮换自动同步原理

1
2
3
4
5
6
7
8
9
10
Vault KV secret 更新(手动或动态凭证)


Vault Sync Engine(后台轮询,约 5 分钟间隔)


GitHub API: PUT /repos/OrgA/my-app/actions/secrets/DB_PASSWORD


GitHub Secret 自动更新(下次 workflow 运行时生效)

在 500+ 仓库规模下,凭证轮换不再需要通知每个仓库负责人——Vault Sync Engine 自动完成推送,平台团队只需管理 Vault 中的 KV,业务仓库的 Secret 自动同步更新。


第七部分:500+ 规模的可观测性

在 500+ 仓库同时运行 CI 的场景下,可观测性不是”加分项”,而是平台运维的基础设施。

Runner 容量监控

1
2
3
4
5
6
7
8
9
# 在每个 job 开始时记录 runner 信息
- name: Log runner info
run: |
echo "Runner: ${{ runner.name }}"
echo "OS: ${{ runner.os }}"
echo "Arch: ${{ runner.arch }}"
echo "Repo: ${{ github.repository }}"
echo "Event: ${{ github.event_name }}"
echo "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)"

CI 健康度 Dashboard

平台团队需要能回答:

  • 过去 7 天,哪 20 个仓库的 CI 失败率最高?
  • 平均 CI 耗时趋势(是否有性能退化)?
  • 安全扫描覆盖率(哪些仓库超过 30 天没有 CI 运行)?

这些数据可以通过 GitHub API 或 CI 运行时的自定义上报来收集。

批量合规检查脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env bash
# 检查所有仓库的 CI 接入状态
echo "检查 ${ORG} 下所有仓库..."
gh repo list "${ORG}" --limit 1000 --json name \
| jq -r '.[].name' \
| while read repo; do
# 检查是否有 .ci-config/config.yaml
if ! gh api "repos/${ORG}/${repo}/contents/.ci-config/config.yaml" \
--silent &>/dev/null; then
echo " [缺少配置] ${repo}"
continue
fi
# 检查是否调用了平台 workflow
if ! gh api "repos/${ORG}/${repo}/contents/.github/workflows/ci.yml" \
--silent 2>/dev/null | grep -q 'platform-ci-core.yml'; then
echo " [未接入平台 CI] ${repo}"
fi
done

第八部分:踩坑经验

1. yq 处理空值的两种姿势

yq 在字段不存在时默认输出字符串 null,导致下游 job 拿到字面量 "null"。在 500+ 仓库中,各种 config.yaml 的写法差异很大,这类问题出现频率高:

1
2
3
4
5
# 方案 A:yq 内联默认值(推荐,简单标量字段)
RCFILE=$(yq '.jobs[].pyLint.rcFile // ""' config.yaml)

# 方案 B:shell 过滤(处理所有 null 输出场景)
RCFILE=$(yq '.jobs[].pyLint.rcFile' config.yaml | grep -v '^null$' || echo "")

2. $GITHUB_OUTPUT 多行值必须用 heredoc

1
2
3
4
5
6
7
8
9
# 错误:换行截断
echo "changelog=${MULTI_LINE_TEXT}" >> "$GITHUB_OUTPUT"

# 正确:heredoc 格式
{
echo "changelog<<EOF"
echo "${MULTI_LINE_TEXT}"
echo "EOF"
} >> "$GITHUB_OUTPUT"

3. Reusable workflow 的 with: 字段只支持字符串

1
2
3
4
5
6
7
8
inputs:
has_unit_test:
type: string # 只能是 string,不能是 boolean

steps:
- name: Run unit tests
if: inputs.has_unit_test == 'true' # 字符串比较
run: pytest

4. pull_request_target + checkout 的安全陷阱

pull_request_target 在 base 仓库上下文运行,但 actions/checkout 默认 checkout base branch,扫描的不是 PR 的代码。

必须显式指定 PR head SHA:

1
2
3
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}

安全注意:checkout 的是 fork 的代码,Secrets 访问逻辑必须与代码 checkout 隔离在不同 job,防止 fork 代码中的恶意脚本读取 Secrets。

5. needs.config.outputsif: 条件里的正确写法

1
2
3
4
5
6
7
build:
needs: config
# 正确:完整的表达式语法
if: ${{ needs.config.outputs.dockerfile != '' }}

# 错误:非 ${{ }} 包裹时,非空字符串不自动为 true
# if: needs.config.outputs.dockerfile

6. 500+ 仓库并发触发时的 runner 队列压力

早上提交高峰期,runner 队列可能积压数百个 job。需要监控 queue_time(job 进入队列到开始运行的时间),并据此调整 runner 数量。超过 5 分钟的队列等待会显著影响开发者体验。


总结

在 500+ 仓库规模下,GitHub Actions Reusable Workflow 方案的核心价值:

  1. 静态结构限制的绕过config job 作为动态配置中间层,替代 Jenkins Groovy 的运行时合并能力
  2. JWT/OIDC 零长期凭证:500 个仓库无一存储凭证,batch token 不可查询,安全边界最大化
  3. 多环境零代码路由:Organization Variable 注入,三套环境用完全相同的 workflow 代码
  4. 多仓库容器构建imagetools create 的 manifest 层复制,节省 500+ 仓库每天数千次构建的带宽
  5. 凭证分层管理:平台共享凭证走 OIDC,业务专属凭证走 Secrets Sync,500 个仓库的凭证生命周期自动化

每个业务仓库最终只需维护 15 行 ci.yml 和一个 .ci-config/config.yaml——这个”最简接入模型”在第 1 个仓库和第 500 个仓库上完全相同,这正是规模化平台的设计目标。