GitHub Actions: 工作流与 CI/CD
GitHub Actions与GitHub Workflows
GitHub Actions、Workflow与CI/CD
GitHub Actions是GitHub提供的自动化平台,可以在代码仓库中自动执行测试、构建、发布和部署等任务CI(Continuous Integration,持续集成)主要解决“代码提交后自动检查”的问题,例如安装依赖、代码检查、单元测试和构建CD通常有两种含义:持续交付(Continuous Delivery)和持续部署(Continuous Deployment)- 持续交付:自动生成可以发布的产物,但生产环境部署通常还需要人工批准
- 持续部署:验证通过后自动部署到目标环境
Workflow是保存在仓库中的一份自动化流程定义,使用YAML编写,文件必须放在.github/workflows/目录下Action是一个可复用的步骤,例如actions/checkout用于检出代码、actions/setup-node用于安装Node.jsRunner是实际执行任务的机器,可以使用GitHub-hosted runner,也可以配置自己的self-hosted runner
官方文档入口:GitHub Actions官方文档。
一条常见的流水线可以抽象成下面的关系:
1 | 事件(push / pull_request) |
例如,开发者向main分支提交代码后,工作流可以自动完成:
1 | 检出代码 → 安装运行环境 → 安装依赖 → 检查与测试 → 构建产物 → 部署 |
Workflow示例
新建.github/workflows/ci.yml:
1 | name: Node CI |
将这个文件提交到仓库后:
push事件会在推送到main或develop时触发pull_request事件会在针对main创建或更新Pull Request时触发workflow_dispatch允许在Actions页面手动运行工作流matrix会为三个Node.js版本分别创建一个任务- 同一个
job中的steps按顺序执行;不同的矩阵任务默认并行执行
示例中的actions/checkout@v4和actions/setup-node@v4是可复用的现成Action。版本标签会随着维护者发布新版本而变化,生产流水线可以进一步将uses固定到经过审查的提交SHA,避免标签被移动后执行到未经预期的代码。
Workflow文件的基本语法
Workflow级配置:决定工作流的名称、权限、变量和并发行为name:工作流在Actions页面显示的名称run-name:某次运行的动态名称,可以使用表达式permissions:GITHUB_TOKEN可以访问的权限env:工作流、任务或步骤使用的环境变量defaults:默认的run配置,例如默认工作目录和shellconcurrency:控制同一组运行是否排队或取消
on:触发事件push:推送提交时触发,可以用branches、tags和paths过滤分支、标签和文件路径pull_request:Pull Request打开、更新或重新打开时触发,常用于代码检查workflow_dispatch:手动触发;多数部署 Workflow 不声明输入参数schedule:按cron表达式定时触发,时间使用UTCworkflow_run:另一个工作流运行结束后触发,适合拆分构建和部署workflow_call:把当前文件作为可复用工作流供其他工作流调用on可以写成一个事件名、事件数组,或者带过滤条件的映射:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16# 任意 push 都触发
on: push
# 多个事件都触发
on: [push, pull_request]
# 带过滤条件
on:
push:
branches:
- main
paths:
- 'src/**'
- 'package.json'
pull_request:
types: [opened, synchronize, reopened]pull_request的目标分支过滤写在branches中;它与提交者推送代码的源分支不是同一个概念。对于来自外部贡献者的Pull Request,不要为了读取密钥而随意改用pull_request_target,否则不可信代码可能获得仓库权限。
jobs:任务runs-on:选择执行环境,例如ubuntu-latest、windows-latest、macos-latestneeds:声明依赖的任务;没有依赖关系的任务默认并行if:根据表达式决定是否运行任务strategy.matrix:为多个系统、语言版本或配置生成任务组合permissions:在任务级别覆盖令牌权限environment:关联staging或production等环境,可以使用环境保护规则和环境密钥outputs:把任务中的步骤输出传给后续任务timeout-minutes:限制任务最长运行时间continue-on-error:允许任务失败后继续,但应谨慎使用,否则容易隐藏真正的问题container和services:分别为任务指定容器或启动数据库等服务容器jobs下的每个键都是一个任务ID,任务至少要指定runs-on和steps:1
2
3
4
5
6
7
8
9
10
11jobs:
build:
runs-on: ubuntu-latest
steps:
- run: npm run build
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: ./deploy.shneeds既可以保证顺序,也可以读取前置任务的输出:1
2
3
4
5
6
7
8
9
10
11
12
13
14jobs:
build:
runs-on: ubuntu-latest
outputs:
artifact-version: ${{ steps.version.outputs.value }}
steps:
- id: version
run: echo "value=build-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploy ${{ needs.build.outputs.artifact-version }}"如果多个任务都依赖
build,它们在build完成后可以并行;只有明确写出needs,才会形成串行关系。
steps:步骤name:步骤在任务日志中的显示名称uses:调用一个可复用的Actionrun:在Runner中执行命令with:向Action传入输入参数env:为步骤设置环境变量id:给步骤命名,之后可以通过steps.<id>.outputs.<name>读取输出if:按条件跳过步骤working-directory:指定命令执行目录shell:指定命令解释器,例如bash或pwshtimeout-minutes:限制步骤运行时间continue-on-error:允许当前步骤失败后继续一个步骤通常使用
run或uses二选一:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21steps:
- name: Run a shell command
run: echo "hello"
- name: Run multiple commands
run: |
npm ci
npm test
- name: Use an Action
uses: actions/checkout@v4
- name: Pass inputs to an Action
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Set environment variables
env:
NODE_ENV: test
run: npm testrun会在Runner的操作系统上执行命令,默认情况下同一个任务中的步骤共享工作目录。uses调用一个已经封装好的Action,with是传给该Action的输入参数。
常用通用 Action
下面这些Action不依赖具体业务,通常可以组合出大多数项目的CI流程。示例中的版本号沿用本文其他示例,实际使用时应根据对应Action的维护状态更新版本,并在重要流水线中考虑固定到提交SHA。
actions/checkout@v4:将仓库代码检出到Runner的工作目录ref:指定要检出的分支、标签或提交fetch-depth:指定检出的提交历史深度,设置为0表示检出完整历史submodules:设置为true或recursive以检出Git submodule
actions/setup-node@v4:安装并配置Node.jsnode-version:指定Node.js版本cache:启用包管理器缓存,可设置为npm、yarn或pnpmregistry-url:配置npm注册表地址
actions/setup-python@v5:安装并配置Pythonpython-version:指定Python版本cache:缓存包管理器依赖,常用值为pip
actions/setup-java@v4:安装并配置JDKdistribution:指定JDK发行版,例如temurinjava-version:指定Java版本cache:缓存Maven或Gradle依赖
actions/cache@v4:缓存依赖目录或中间构建结果,减少重复下载和计算path:需要缓存的文件或目录key:当前缓存的唯一键,通常包含操作系统和锁文件哈希restore-keys:精确键未命中时使用的备用键前缀- 缓存只负责复用文件,不能代替
npm ci、pip install等安装命令
actions/upload-artifact@v4:保存本次运行生成的构建结果、测试报告等产物name:产物名称path:需要上传的文件或目录retention-days:产物保留天数
actions/download-artifact@v4:在后续任务中下载构建任务上传的产物name:要下载的产物名称path:产物下载目录upload-artifact和download-artifact通常成对使用;它们适合在不同job之间传递构建结果,不能与依赖缓存混用
docker/login-action@v3:登录容器镜像仓库registry:镜像仓库地址,例如ghcr.iousername和password:登录凭据,敏感值应通过Secrets传入
docker/build-push-action@v6:构建并可选地推送Docker镜像context:镜像构建上下文目录file:Dockerfile路径push:是否推送构建结果tags:镜像标签platforms:需要构建的目标平台
azure/k8s-set-context@v4:使用 kubeconfig 设置当前任务的 Kubernetes 上下文,后续的kubectl和helm命令都会连接到这个集群method:认证方式,使用 kubeconfig 时填写kubeconfigkubeconfig:kubeconfig 内容,通常通过Secrets传入context:kubeconfig 中要使用的集群上下文
1
2
3
4
5
6- uses: azure/k8s-set-context@v4
with:
method: kubeconfig
kubeconfig: ${{ secrets.KUBECONFIG }}
context: example-cluster
- run: helm upgrade --install example-app ./charts/example-app
自定义 actions
自定义 Action 用于把重复步骤封装成一个可复用单元。下面以
composite Action 为例,示例不依赖具体仓库或业务。
目录:在
.github/actions/<name>/action.yml或action.yaml中定义,Workflow 通过相对路径调用。1
2
3
4.github/
└── actions/
└── setup-node/
└── action.ymlname:Action 的显示名称description:Action 的用途说明inputs:调用方可以传入的参数;composite Action 接收到的输入按字符串处理,布尔开关通常比较'true'或'false'runs:指定 Action 的实现方式;composite用于组合 Shell 命令和其他 Action,node20用于 JavaScript Action,docker用于 Docker container Action1
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
34name: Setup Node.js if needed
description: Set up Node.js only when it is not installed
inputs:
node-version:
description: Node.js version
required: false
default: "22"
install-yarn:
description: Install Yarn with Corepack
required: false
default: "false"
version-file-path:
description: Directory containing the package manager files
required: false
default: ${{ github.workspace }}
runs:
using: composite
steps:
- id: nodejs-installed
shell: bash -exo pipefail {0}
run: |
if command -v node >/dev/null 2>&1; then
echo "version=$(node -v)" >> "$GITHUB_OUTPUT"
fi
- if: ${{ steps.nodejs-installed.outputs.version == '' }}
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- if: ${{ inputs.install-yarn == 'true' }}
shell: bash -exo pipefail {0}
run: |
cd ${{ inputs.version-file-path }}
corepack enable
corepack installshell:composite Action 中的run步骤显式设置 Shell,否则元数据文件校验会失败。1
2
3
4
5
6steps:
- uses: ./.github/actions/setup-node
with:
node-version: "22"
install-yarn: "true"
version-file-path: ${{ github.workspace }}/example-sitegh workflow run:自定义 Action 可以把另一个 Workflow 当作子流程调用,并用-F传入它的workflow_dispatch.inputs。1
2
3
4
5gh auth login --with-token <<< "$GITHUB_TOKEN"
gh workflow run domain-transfer.yaml \
-F domain="$ROOT_DOMAIN" \
-F nameservers="$NAMESERVERS" \
-F create-zone=falsebuild-images:先记录 Runner 是否已有 Java;没有时才安装,再根据push输入决定只构建还是发布镜像。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
26inputs:
push:
required: false
default: "false"
project:
required: true
runs:
using: composite
steps:
- id: installed-java
shell: bash
run: |
if command -v java &> /dev/null; then
echo "version=$(java -version 2>&1 | head -n 1 | awk -F '"' '{print $2}')" >> "$GITHUB_OUTPUT"
fi
- if: ${{ steps.installed-java.outputs.version == '' }}
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- if: ${{ inputs.push != 'true' }}
shell: bash
run: ./gradlew :${{ inputs.project }}:bootBuildImage -x spotlessCheck
- if: ${{ inputs.push == 'true' }}
shell: bash
run: ./gradlew :${{ inputs.project }}:bootBuildImage --publishImage --parallel -x spotlessChecksetup-node-if-needed:把“检查命令是否存在、按需安装、配置缓存”封装成一个步骤。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20- id: nodejs-installed
shell: bash -exo pipefail {0}
run: |
if command -v node >/dev/null 2>&1; then
echo "version=$(node -v)" >> "$GITHUB_OUTPUT"
fi
- if: steps.nodejs-installed.outputs.version == ''
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- if: inputs.install-yarn == 'true'
shell: bash -exo pipefail {0}
run: |
cd ${{ inputs.version-file-path }}
corepack enable
corepack install
- if: steps.nodejs-installed.outputs.version == ''
uses: actions/setup-node@v4
with:
cache: ${{ inputs.install-yarn == 'true' && 'yarn' || 'npm' }}deploy-job:把完整镜像名拆成 registry、repository 和 tag,再把多行key=value转换成多个 Helm--set参数。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
26FULL_IMAGE="${{ inputs.image }}"
EXTRA_SET_ARGS="${{ inputs.extra-set-args }}"
REGISTRY="${FULL_IMAGE%%/*}"
REST="${FULL_IMAGE#*/}"
REPO="${REST%:*}"
TAG="${REST##*:}"
HELM_EXTRA=()
while IFS= read -r line; do
[[ -z "$line" ]] && continue
key="${line%%=*}"
val="${line#*=}"
HELM_EXTRA+=(--set "${key}=${val//,/\\,}")
done <<< "$EXTRA_SET_ARGS"
helm upgrade --install "$RELEASE_NAME" ./charts/example-job \
--namespace "$NAMESPACE" \
--values "$VALUES_FILE" \
--set "image.registry.url=${REGISTRY}" \
--set "image.repository=${REPO}" \
--set "image.tag=${TAG}" \
"${HELM_EXTRA[@]}" \
--wait \
--timeout=5m \
--atomic
表达式、上下文与环境变量
表达式语法
GitHub Actions使用${{ }}计算表达式:
1 | env: |
在if字段中通常可以省略${{ }},但保留它更容易辨认这是工作流表达式。常用运算符和函数包括:
- 比较:
==、!=、&&、|| - 字符串:
contains()、startsWith()、endsWith()、format() - 文件:
hashFiles('**/package-lock.json') - 状态:
success()、failure()、cancelled()、always() - 转换:
fromJSON()、toJSON()
例如只允许main分支部署,并在失败时执行清理:
1 | jobs: |
常用上下文
上下文是由GitHub Actions提供的只读数据:
| 上下文 | 常见用途 |
|---|---|
github |
仓库、分支、提交、事件和触发者,例如github.ref |
env |
当前工作流、任务或步骤定义的环境变量 |
vars |
仓库、组织或环境级别的非敏感配置变量 |
secrets |
仓库、组织或环境级别的敏感信息 |
runner |
执行机器的信息,例如操作系统和临时目录 |
matrix |
当前矩阵任务的参数 |
steps |
当前任务中带有id的步骤输出 |
needs |
前置任务的结果和任务输出 |
inputs |
手动触发或可复用工作流的输入 |
表达式和run脚本中的环境变量不是同一种语法:
1 | steps: |
在步骤之间传递数据
步骤输出写入GITHUB_OUTPUT,环境变量写入GITHUB_ENV:
1 | steps: |
GITHUB_OUTPUT只适合传递步骤输出,不能把一个任务中的文件直接带到另一个任务。每个任务通常使用全新的Runner,跨任务传文件应该使用Artifact。
Matrix:批量执行同一类任务
Matrix会把一个job展开成多个独立任务,每个任务都有自己的Runner、工作目录、日志和状态。它适合处理一批结构相同但参数不同的任务,不需要为每个模块、镜像或部署对象复制一份job。
简单值矩阵:API 工作流把多个模块拆成并行测试任务;
needs: test的后续任务会等待所有模块测试结束。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15jobs:
test:
if: ${{ !needs.skip-workflow.outputs.skip }}
needs: [skip-workflow]
name: "test: ${{ matrix.module }}"
runs-on: example-runner-low-docker
strategy:
fail-fast: true
matrix:
module:
- example-bot
- example-jobs
steps:
- uses: actions/checkout@v4
- run: ./gradlew :${{ matrix.module }}:test对象矩阵:镜像构建任务把项目名和 Runner 规格放在同一行;没有指定 Runner 的项目使用默认值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17jobs:
push-docker:
name: "docker: ${{ matrix.project.name }}"
runs-on: ${{ matrix.project.runner || 'example-runner-low-docker' }}
strategy:
fail-fast: true
matrix:
project:
- name: example-api
runner: example-runner-high
- name: example-bot
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/build-docker-images
with:
project: ${{ matrix.project.name }}
push: ${{ github.event_name != 'pull_request' }}动态矩阵:部署上下文不是直接写死在 Workflow 中,而是先由脚本扫描目录生成 JSON,再用
fromJson()把输出转换为矩阵。1
2
3
4
5
6
7results="[]"
while read -r context; do
runner=example-runner-mini
results=$(echo "$results" | jq ". += [{context: \"$context\", runner: \"$runner\"}]")
done < <(ls deploy/values)
echo "$results" | jq -c .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
28jobs:
contexts-matrix:
runs-on: example-runner
outputs:
contexts: ${{ steps.contexts.outputs.contexts }}
steps:
- uses: actions/checkout@v4
- id: contexts
run: |
matrix=$(./scripts/contexts-matrix.sh)
echo "contexts<<EOF" >> "$GITHUB_OUTPUT"
echo "$matrix" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
deploy-apps:
needs: contexts-matrix
strategy:
fail-fast: false
matrix:
context: ${{ fromJson(needs.contexts-matrix.outputs.contexts) }}
runs-on: ${{ matrix.context.runner }}
steps:
- uses: actions/checkout@v4
- uses: azure/k8s-set-context@v4
with:
method: kubeconfig
kubeconfig: ${{ secrets.KUBECONFIG }}
context: ${{ matrix.context.context }}这个矩阵的每一行同时携带 Kubernetes context 和 Runner 名称,因此矩阵不仅决定部署多少次,还决定每次部署连接哪个集群、使用哪类 Runner。
include矩阵:多个 CronJob 共用同一套部署步骤,只把名称、镜像和 values 文件作为每一行的参数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17jobs:
deploy-jobs:
strategy:
fail-fast: false
matrix:
include:
- name: sync-data
image-name: example-job
- name: refresh-cache
image-name: example-job
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/deploy-job
with:
release-name: example-${{ matrix.name }}
values-file: deploy/example/${{ matrix.name }}.yaml
image: ${{ matrix.image-name }}基础设施矩阵:基础设施工作流把产品配置和集群配置组合起来,每个矩阵任务部署一个组件。
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
26jobs:
deploy:
name: "Deploy ${{ matrix.product.name }} ${{ matrix.cluster.name }}"
runs-on: ${{ matrix.cluster.runner }}
strategy:
fail-fast: false
matrix:
product:
- name: example-ingress
chart: ingress
namespace: istio-system
cluster:
- name: default
runner: example-runner-mini
context: example-cluster
steps:
- uses: actions/checkout@v4
- uses: azure/k8s-set-context@v4
with:
method: kubeconfig
kubeconfig: ${{ secrets.KUBECONFIG }}
context: ${{ matrix.cluster.context }}
- run: |
helm upgrade --install ${{ matrix.product.name }} \
./charts/${{ matrix.product.chart }} \
--namespace ${{ matrix.product.namespace }}fail-fast:测试和镜像构建矩阵使用true,一个任务失败时尽快停止同组任务;部署矩阵使用false,避免一个组件失败后取消其他组件的部署。
缓存、产物与任务依赖
Cache与
Artifact的区别
Cache用于加速重复执行,例如缓存npm、pip或Maven依赖;缓存失效时仍然应该能够重新安装Artifact用于保存本次运行生成的文件,例如构建目录、测试报告和安装包,并在后续任务中下载- 不要把缓存当作可靠的发布存储,也不要把密钥或敏感文件上传为产物
使用setup-node缓存npm依赖:
1 | steps: |
使用产物连接构建任务和部署任务:
1 | jobs: |
部署任务不应该再次从分支检出并重新构建,而应该部署已经通过检查的构建产物。这样可以减少“测试的代码”和“实际部署的代码”不一致的可能性。
常见 CI/CD 流程
Pull Request 检查
最基础的CI只做验证,不写入仓库,也不部署生产环境:
1 | name: Pull Request CI |
如果push和pull_request都指向同一个提交,可能会产生两次运行。可以根据团队习惯只保留一种触发方式,或者使用concurrency取消同一分支上过时的运行:
1 | concurrency: |
构建后部署
一个典型的持续部署工作流是“构建和验证成功后才能部署”:
1 | name: Build and deploy |
environment: production可以配合环境保护规则配置审批人、部署分支限制和环境级别的密钥。这样,Pull Request仍然可以执行构建和测试,但不会因为deploy任务存在而自动发布。
发布容器镜像
发布到GitHub Container Registry时,通常只在main或发布标签上执行,并授予任务最小的packages: write权限:
1 | name: Publish image |
tricks
Workflow 的执行顺序
Workflow 中的任务由 needs
连接,文件中的先后顺序不决定执行顺序。以 API 部署流程为例:
1 | skip-workflow |
needs:没有依赖的任务可以并行运行;deploy-apps等待测试、镜像构建和部署上下文都完成,notification再等待部署完成。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
28jobs:
test:
needs: [skip-workflow]
push-docker:
needs: [skip-workflow]
build-helper-images:
needs: [skip-workflow]
contexts-matrix:
needs: [skip-workflow]
deploy-apps:
if: ${{ github.event_name != 'pull_request' && !needs.skip-workflow.outputs.skip }}
needs:
- skip-workflow
- push-docker
- build-helper-images
- contexts-matrix
- test
notification:
if: ${{ github.event_name != 'pull_request' && !needs.skip-workflow.outputs.skip }}
needs:
- skip-workflow
- push-docker
- deploy-apps
push 与
pull_request
合理的顺序是先准备环境,再恢复缓存,然后检查代码和测试,最后构建镜像。在这个流程里,只有
push 才登录镜像仓库、推送镜像和部署:
1 | pull_request: |
共同 steps:两个事件都先检出代码、准备工具链、恢复缓存,再运行测试和静态检查。Gradle 的缓存由
setup-gradle在这一步处理。1
2
3
4
5
6
7
8steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- uses: gradle/actions/setup-gradle@v6
- run: ./gradlew testpull_request:只验证和构建,不登录私有镜像仓库,不推送镜像;下面的构建和报告步骤接在共同 steps 后。1
2
3
4
5
6
7
8
9- uses: docker/build-push-action@v5
with:
context: .
push: false
- if: failure()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: "**/build/reports/tests/test/"push:共同 steps 成功后登录镜像仓库、构建并推送镜像;部署作为后续 job,通过needs等待构建完成。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16- uses: docker/login-action@v3
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- uses: docker/build-push-action@v5
with:
context: .
push: true
deploy:
if: ${{ github.event_name == 'push' }}
needs: build-image
steps:
- uses: actions/checkout@v4
- run: helm upgrade --install example-app ./charts/example-appspotless-check:格式检查只放在 Pull Request 分支上,不进入 push 的部署链路;步骤顺序是检出代码、准备 Java、准备 Gradle、执行格式检查。1
2
3
4
5
6
7
8
9
10
11
12
13jobs:
spotless-check:
if: ${{ github.event_name == 'pull_request' && !needs.skip-workflow.outputs.skip }}
needs: [skip-workflow]
runs-on: example-runner-mini
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- uses: gradle/actions/setup-gradle@v6
- run: ./gradlew spotlessCheck
缓存
实际有三种缓存方式,以及一个共享缓存后端,作用不同。
缓存 Action
执行到对应步骤时先恢复缓存;如果没有命中,任务继续执行,作业结束时再保存新缓存。显式
actions/cache 的 key
由系统、项目和锁文件哈希组成,restore-keys
只用于回退到同一项目的旧缓存。
gradle/actions/setup-gradle:缓存 Gradle User Home;cache-read-only: false允许构建任务写入缓存。1
2
3
4- uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
- run: ./gradlew testactions/setup-node:通过cache缓存 npm 或 Yarn 依赖。1
2
3
4
5- uses: actions/setup-node@v4
with:
node-version: "22"
cache: yarn
- run: yarn install --immutableactions/cache:自己指定目录、精确键和备用键;Yarn 缓存使用锁文件哈希区分版本。1
2
3
4
5
6- uses: actions/cache@v4
with:
path: websites/example-site/.yarn/cache
key: ${{ runner.os }}-yarn-example-site-${{ hashFiles('websites/example-site/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-example-site-ACTIONS_RESULTS_URL:self-hosted Runner 将 Action 结果和缓存请求指向集群内的 cache server;Runner 的runner容器使用自定义镜像,Docker 构建使用dind容器。1
2
3
4
5
6
7
8
9
10
11containers:
- name: runner
image: registry.example.com/example/actions-runner:${DOCKER_TAG}
env:
- name: ACTIONS_RESULTS_URL
value: http://example-cache-github-actions-cache-server.arc-systems.svc.cluster.local/
- name: dind
image: registry.example.com/example/docker:28.1.1-dind
args:
- dockerd
- --host=unix:///var/run/docker.sock
Docker 构建没有配置 cache-from 或
cache-to;Docker-in-Docker 数据目录只是 Runner
层面的本地存储,不等于共享的 Docker registry 缓存。
Runner、镜像和缓存服务器
runs-on:普通构建和测试使用 self-hosted Runner;部署 cache server 或 Runner 控制器的任务可以使用ubuntu-latest。1
2
3
4# 测试、镜像构建
runs-on: example-runner-low-docker
# 部署、通知
# runs-on: example-runnerBASE_IMAGE_REGISTRY:PR 使用mirror.gcr.io获取基础镜像;push 使用私有 registry。Testcontainers 也按事件切换镜像前缀。1
2
3env:
BASE_IMAGE_REGISTRY: ${{ github.event_name == 'pull_request' && 'mirror.gcr.io' || format('{0}/example', secrets.DOCKER_REGISTRY) }}
TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX: ${{ github.event_name == 'pull_request' && 'mirror.gcr.io/' || format('{0}/example/', secrets.DOCKER_REGISTRY) }}cache server 使用公开镜像
ghcr.io/falcondev-oss/github-actions-cache-server:8.0.0,通过 Helm 部署,并用持久卷保存/app/.data。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16image:
repository: ghcr.io/falcondev-oss/github-actions-cache-server
tag: "8.0.0"
persistentVolumeClaim:
enabled: true
template:
metadata:
name: github-cache-0
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 300Gi
storageClassName: github-cache-01
2
3
4helm upgrade --install example-cache \
--namespace arc-systems \
--create-namespace \
./charts/github-actions-cache-server
触发器与并发控制
workflow_dispatch:部署文件大多写成空配置,只提供手动重跑入口;它和push、pull_request并列。- 手动运行时选择的是 Workflow 的 ref;普通部署的环境由
github.ref_name、github.ref或固定的NAMESPACE决定,这类部署文件不把环境做成手动输入。
1
2
3
4
5
6
7
8
9
10on:
push:
branches:
- main
paths:
- websites/gen2/**
- websites/chart/**
- .github/workflows/deploy-websites.yaml
- .github/workflows/deploy-root-websites.yaml
workflow_dispatch:- 手动运行时选择的是 Workflow 的 ref;普通部署的环境由
paths-ignore:排除大目录后,用!恢复需要参与触发的子目录。1
2
3
4
5
6
7
8
9
10
11on:
push:
paths-ignore:
- websites/**
- .github/actions/**
- "!.github/actions/build-docker-images/**"
- .github/workflows/**
- "!.github/workflows/deploy-api.yaml"
- charts/**
- "!charts/spring-app/**"
- "!charts/spring-job/**"workflow_run:部署 Workflow 同时支持手动触发和上游 Workflow 成功后的触发;检出head_sha,并从上游运行的分支计算命名空间。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
26on:
workflow_dispatch:
workflow_run:
workflows:
- Deploy example-app.com
types:
- completed
branches:
- main
jobs:
deploy:
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: example-runner
env:
NAMESPACE: ${{ (github.event.workflow_run.head_branch || github.ref_name) == 'main' && 'production' || 'staging' }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- id: image-tag
run: |
SHORT_SHA="${HEAD_SHA:0:7}"
echo "tag=${HEAD_BRANCH}-${SHORT_SHA}" >> "$GITHUB_OUTPUT"workflow_dispatch.inputs:只有少数运维 Workflow 定义输入:transfer-domains.yaml接收域名和 nameserver,copy-docker-image.yaml接收镜像名,create-ai-deployment.yaml接收生成部署文件所需的参数。这些输入描述要执行的运维动作,不是普通部署 Workflow 的环境选择。1
2
3
4
5
6
7
8
9
10
11on:
workflow_dispatch:
inputs:
domain:
required: true
nameservers:
required: false
create-zone:
required: false
type: boolean
default: true1
2
3
4
5
6
7DOMAINS="${{ inputs.domain }}"
IFS=',' read -ra DOMAIN_ARRAY <<< "$DOMAINS"
for DOMAIN in "${DOMAIN_ARRAY[@]}"; do
DOMAIN=$(echo "$DOMAIN" | xargs)
# 处理单个 DOMAIN
done1
2
3
4
5
6
7
8source="${{ github.event.inputs.image }}"
ref="${source##*/}"
if [[ "$ref" != *:* && "$ref" != *@* ]]; then
source="${source}:latest"
fi
target="${DOCKER_REGISTRY}/${source}"
docker buildx imagetools create -t "$target" "$source"1
2
3
4
5
6
7
8
9
10
11
12
13on:
workflow_dispatch:
inputs:
api_only:
required: false
default: false
type: boolean
prod_only:
required: false
default: false
type: boolean
env:
FULL_DOMAIN: ${{ inputs.full_domain || format('{0}.{1}', inputs.id, inputs.domain) }}concurrency.group:deploy-api.yaml使用 Pull Request 编号,否则使用分支或标签引用;同一目标的新运行会取消旧运行。1
2
3concurrency:
group: deploy-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
任务级配置
defaults.run:统一 Shell,并在需要时设置默认工作目录。1
2
3
4defaults:
run:
shell: bash -exo pipefail {0}
working-directory: websites/scripts/domain-transferenv:在工作流级别根据分支和事件生成后续任务使用的值。1
2
3env:
NAMESPACE: ${{ github.ref_name == 'main' && 'production' || 'staging' }}
BASE_IMAGE_REGISTRY: ${{ github.event_name == 'pull_request' && 'mirror.gcr.io' || format('{0}/example', secrets.DOCKER_REGISTRY) }}
任务跳过、输出与矩阵
outputs:skip-workflow是仓库自定义的检查任务,不是 GitHub Actions 的特殊任务名。提交信息包含[skip workflow]时,它输出skip=true;后续任务通过needs.skip-workflow.outputs.skip跳过。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
30jobs:
skip-workflow:
runs-on: example-runner
outputs:
skip: ${{ steps.skip.outputs.skip }}
steps:
- uses: actions/checkout@v4
- name: Check commit message
id: skip
run: |
commit_message=$(cat <<'EOF'
${{ github.event.head_commit.message }}
EOF
)
if echo "$commit_message" | grep -qF '[skip workflow]'; then
echo "skip=true" | tee -a "$GITHUB_OUTPUT"
fi
test:
needs: skip-workflow
if: ${{ !needs.skip-workflow.outputs.skip }}
runs-on: example-runner-low-docker
strategy:
matrix:
module:
- example-bot
- example-jobs
steps:
- uses: actions/checkout@v4
- run: ./gradlew :${{ matrix.module }}:test1
2git commit -m "docs: update [skip workflow]"
git pushGITHUB_OUTPUT:deploy-api.yaml对 Pull Request 全部构建;其他事件先执行git diff,也支持[rebuild helpers]强制重建。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25jobs:
build-helper-images:
strategy:
matrix:
project: [init-database, set-dns, shell-sandbox]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- id: detect
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
elif grep -qF '[rebuild helpers]' <<< "${{ github.event.head_commit.message }}"; then
echo "changed=true" >> "$GITHUB_OUTPUT"
elif git diff --quiet HEAD~1 HEAD -- "docker/${{ matrix.project }}"; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- if: ${{ steps.detect.outputs.changed == 'true' }}
uses: docker/build-push-action@v5
with:
context: ./docker/${{ matrix.project }}
push: ${{ github.event_name != 'pull_request' }}strategy.matrix:先生成上下文 JSON,再用fromJson()生成部署矩阵。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
28jobs:
contexts-matrix:
if: ${{ !needs.skip-workflow.outputs.skip }}
needs: skip-workflow
runs-on: example-runner
outputs:
contexts-matrix: ${{ steps.contexts-matrix.outputs.contexts-matrix }}
steps:
- uses: actions/checkout@v4
- id: contexts-matrix
run: |
matrix=$(./scripts/contexts-matrix.sh)
echo "contexts-matrix<<EOF" >> "$GITHUB_OUTPUT"
echo "$matrix" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
deploy-apps:
if: ${{ github.event_name != 'pull_request' && !needs.skip-workflow.outputs.skip }}
needs:
- skip-workflow
- contexts-matrix
strategy:
fail-fast: false
matrix:
context: ${{ fromJson(needs.contexts-matrix.outputs.contexts-matrix) }}
runs-on: ${{ matrix.context.runner }}
steps:
- uses: actions/checkout@v4workflow_call:deploy-app.yaml把产品配置作为 JSON 字符串传入,在被调用 Workflow 中解析。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24on:
workflow_call:
inputs:
product:
required: true
type: string
namespace:
required: true
type: string
github-sha:
required: true
type: string
commit-message:
required: true
type: string
jobs:
deploy-app:
name: ${{ fromJson(inputs.product).context }}: ${{ fromJson(inputs.product).name }}
runs-on: ${{ fromJson(inputs.product).runner || 'example-runner' }}
steps:
- run: |
helm -n "${{ inputs.namespace }}" upgrade "${{ fromJson(inputs.product).name }}" \
--install "./charts/${{ fromJson(inputs.product).chart }}"
镜像构建与部署
github.event_name:Pull Request 只构建不推送,部署任务直接跳过。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17jobs:
build-image:
runs-on: example-runner-low-docker
steps:
- uses: actions/checkout@v4
- uses: docker/build-push-action@v5
with:
context: ./websites/example-site
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
deploy:
if: ${{ github.event_name != 'pull_request' }}
needs: build-image
runs-on: example-runner
steps:
- uses: actions/checkout@v4docker/metadata-action:先根据事件生成标签规则,再将多行输出交给 Action。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
36steps:
- id: metadata-tags
run: |
tags_file=$(mktemp)
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
cat <<'EOF' > "$tags_file"
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
EOF
else
cat <<'EOF' > "$tags_file"
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
EOF
fi
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
cat "$tags_file" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ github.event_name != 'pull_request' && format('{0}/{1}', env.DOCKER_REGISTRY, env.IMAGE_NAME) || env.IMAGE_NAME }}
tags: ${{ steps.metadata-tags.outputs.tags }}
- uses: docker/build-push-action@v5
with:
context: ./websites/example-site
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}helm:先检查 Runner 中是否已有 Helm,没有时才安装。1
2
3
4
5
6
7
8steps:
- id: helm-installed
run: |
if command -v helm &> /dev/null; then
echo "helm-version=$(helm version --client --template='{{.Version}}')" >> "$GITHUB_OUTPUT"
fi
- if: ${{ steps.helm-installed.outputs.helm-version == '' }}
uses: azure/setup-helm@v4.2.0helm upgrade:部署失败时区分“操作进行中”和普通失败;前者回滚后继续,后者等待后重试。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18for retry in {1..10}; do
res=$(helm -n "$NAMESPACE" upgrade "$name" \
--install ./charts/$chart \
--create-namespace \
-f "$values_file" \
-f "$amd_value_file" \
--set image.repository="$image" \
--set image.tag="$tag" 2>&1)
if [[ $? -eq 0 ]]; then
break
fi
if [[ "$res" = *"another operation (install/upgrade/rollback) is in progress"* ]]; then
helm -n "$NAMESPACE" rollback "$name"
continue
fi
sleep 10
done
部署辅助
mktemp、envsubst和yq:先生成临时 values 文件,再写入部署专属字段。1
2
3
4
5
6
7
8
9
10
11
12
13value_file=$(mktemp).yaml
yq eval '.gateway.hosts = []' websites/chart/values.yaml | tee "$value_file"
cat <<EOF >> "$value_file"
tolerations:
- key: example.io/environment
operator: Equal
value: "$NAMESPACE"
effect: NoSchedule
EOF
values_file=$(mktemp).yaml
envsubst '$PUBLIC_VAR $REGISTRY' < deploy/values/template.yaml > "$values_file"GITHUB_STEP_SUMMARY:批处理任务逐项写入表格,最后统一判断失败。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18IFS=',' read -ra ITEMS <<< "$INPUT"
FAILED_COUNT=0
echo "| Item | Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|------|--------|" >> "$GITHUB_STEP_SUMMARY"
for ITEM in "${ITEMS[@]}"; do
if python main.py --domain "$ITEM" --nameservers "$NAMESERVERS"; then
echo "| $ITEM | Success |" >> "$GITHUB_STEP_SUMMARY"
else
echo "| $ITEM | Failed |" >> "$GITHUB_STEP_SUMMARY"
FAILED_COUNT=$((FAILED_COUNT + 1))
fi
done
if [ "$FAILED_COUNT" -gt 0 ]; then
exit 1
fipersist-credentials:自动格式化前关闭检出步骤的凭据持久化,只在存在变更时提交。1
2
3
4
5
6
7
8
9
10
11steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- run: ./gradlew spotlessApply
- run: |
if ! git diff --name-only --exit-code; then
git add .
git commit -m "ci(bot): format"
git push
fi
自托管服务下的 CD 顺序
在 GitHub 或 GHES 负责仓库和调度、其余服务均为自托管的情况下,CD 主链如下:
flowchart TD
A[提交代码或手动触发] --> B[GitHub Actions 调度器]
B --> C[ARC Controller]
C --> D[Kubernetes Runner Pod]
D -. 读取/保存缓存 .-> E[私有 Cache Server]
E --> F[(PVC 或对象存储)]
D --> G[Checkout、测试、构建镜像]
G --> H[推送到私有镜像仓库]
H --> I[azure/k8s-set-context]
I --> J[Helm upgrade/install]
J --> K[Kubernetes API Server]
K --> L[创建或更新 Deployment]
L --> M[应用 Pod]
M -. imagePullSecret 拉取镜像 .-> H
K --> N[post-install / post-upgrade Jobs]
N --> O[init-database]
O --> P[私有数据库]
N --> Q[set-dns]
Q --> R[私有 DNS 服务商]
L --> S[rollout status / 健康检查]
N --> S
S --> T[通知结果]
缓存服务器是 Runner 的旁路服务,不是 CD
主链中的必经步骤。如果镜像已经在 CI 阶段推送完成,CD
可以从azure/k8s-set-context开始。
GitHub 与私有 Runner 的连接
GitHub 不需要访问 Kubernetes 的私网地址。ARC Listener 和 Runner 都从 Kubernetes 内部主动向 GitHub 建立出站连接:
flowchart LR
subgraph K8s[私有 Kubernetes 集群]
C[ARC Controller]
L[ARC Listener]
R[Runner Pod]
C -->|管理 Kubernetes 资源| L
C -->|创建和销毁| R
end
L -->|HTTPS 出站连接| G[GitHub Actions 服务]
R -->|接收任务、上传日志和结果| G
W[Workflow: runs-on: example-runner] --> G
G -->|匹配已注册的 Runner Scale Set| L
githubConfigUrl决定 Runner 注册到哪个组织或仓库,githubConfigSecret提供 ARC 访问 GitHub API 所需的 GitHub App 凭据。- ARC Listener 注册 Runner Scale Set 后,GitHub
会记录它的名称和标签。Workflow
的
runs-on只匹配这个名称,不匹配私网 IP。 - 任务进入队列后,Listener 获取任务并通知 Controller,Controller 再让 Kubernetes 创建 Runner Pod。
- Runner Pod 完成任务后,仍然通过出站连接向 GitHub 上传日志和执行结果;GitHub 不需要主动进入私有网络。
- 因此私有集群至少需要能够解析并通过 HTTPS 访问 GitHub Actions 相关服务。没有公网出口时,需要配置 NAT、代理或其他出站网关。