GitHub Actions + Docker + ACR + GHCR 自动化构建与发布

GitHub Actions + Docker + 阿里云 ACR + GitHub Container Registry(GHCR)自动化构建与发布工作流。

一、先理解 CI/CD 到底是什么

  • CI(持续集成):代码提交后,自动检查代码是否有问题、能否构建、基本功能能否运行。
  • CD(持续交付 / 持续部署):把验证通过的程序交付出去,例如发布 Docker 镜像;如果进一步自动更新生产服务器,才是完整的自动部署流程。

你的这个工作流已经包含了自动测试和镜像发布,但从这份 YAML 来看,它没有直接登录你的生产服务器,也没有执行服务器上的 docker compose pull 或 docker compose up -d。所以它完成的是“构建、测试、发布镜像”,不是自动更新运行中的服务。

GitHub Actions 的核心机制是:事件触发工作流,工作流运行一个或多个 Job,每个 Job 再依次执行 Steps。

二、你的工作流具体做了什么?

查看这份文件。它只有一个 Job,但包含多个按顺序执行的步骤。

阶段 你的工作流做什么 目的
1. 触发 监听 main 分支的 push,也支持手动运行 自动启动流水线
2. 获取代码 Checkout 仓库 把代码下载到临时机器
3. 前置检查 检查文件和 ACR 配置 提前发现配置错误
4. 代码检查 设置 Python 3.12,检查 Python 语法 避免低级错误
5. 构建应用镜像 构建 Flask/Python 应用镜像 验证应用能否打包
6. 冒烟测试 启动应用容器,访问 /health 验证应用基本可用
7. 构建 Nginx 镜像 构建 Web 入口镜像 验证 Nginx 能否打包
8. 配置检查 执行 nginx -t 检查 Nginx 配置
9. 镜像发布 登录两个仓库,推送镜像 发布可供部署的镜像

这里有一个特别值得学习的设计:测试通过之前,不会执行最后的镜像发布步骤。 如果前面的步骤失败,后续普通步骤默认会跳过。

1. 为什么是两个镜像?

应用镜像(app)

包含 Python 应用及依赖,负责文件服务器的后端逻辑。

示例:file-server-app-ci:版本号

Web 镜像(nginx)

包含 Nginx 和相关配置,负责 Web 入口、静态页面或反向代理。

示例:file-server-nginx-ci:版本号

这两个镜像会分别推送到两个仓库,因此按这份脚本的逻辑,最终有 4 个镜像仓库路径,每个路径 3 个标签,共 12 次镜像标签推送操作。这不代表构建了 12 个不同的镜像内容:同一个镜像的不同标签通常指向相同的镜像内容。

三、理解最重要的几个概念

1. on:什么情况下执行?

1
2
3
4
5
on:
push:
branches:
- main
workflow_dispatch:
  • push:有人向仓库推送代码时触发。
  • branches: - main:只响应 main 分支的 push。
  • workflow_dispatch:允许你在 GitHub 的 Actions 页面手动点击运行。

注意:这并不意味着所有分支的代码都会自动发布。比如你在 dev 分支提交代码,默认不会触发这个工作流;合并到 main 后才会触发。

2. jobs 和 steps:流水线与具体任务

1
2
3
4
5
6
jobs:
build-test-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4

可以这样理解:

  • jobs:定义需要执行的任务。
  • build-test-publish:Job 的内部标识符。
  • runs-on:指定运行环境,这里是 GitHub 托管的 Ubuntu Linux 机器。
  • steps:任务中的具体步骤。
  • uses:调用别人已经编写好的 Action。
  • run:直接执行 Shell 命令。

例如,actions/checkout@v4 就是一个可复用的 Action,负责将仓库代码检出到工作目录。

3. ${{ ... }} 和 $VARIABLE 有什么区别?

这是阅读 GitHub Actions 时很容易混淆的地方。

写法 由谁处理 示例
${{ github.sha }} GitHub Actions 表达式引擎 获取本次提交的完整 SHA
${{ vars.ACR_IMAGE }} GitHub Actions 表达式引擎 读取仓库变量
${{ secrets.ACR_PASSWORD }} GitHub Actions 表达式引擎 读取密码 Secret
$ACR_IMAGE Bash Shell 读取已注入环境的变量
${GITHUB_SHA} Bash Shell 读取当前提交 SHA 对应的环境变量
"$TAG" Bash Shell 读取标签变量,并防止普通空格导致参数拆分

比如:

1
2
3
4
5
6
{% raw %}
env:
ACR_IMAGE: ${{ vars.ACR_IMAGE }}
run: |
docker push "${ACR_IMAGE}:latest"
{% endraw %}

GitHub Actions 先把仓库变量注入环境,Bash 执行命令时再读取 $ACR_IMAGE。

记住:${{ ... }} 是工作流表达式,$VARIABLE 是 Shell 变量。 两者处于不同的处理阶段。

4. vars、secrets 和 GITHUB_TOKEN

你的工作流使用了这几种配置:

名称 类型 用途
vars.ACR_REGISTRY 仓库变量 阿里云 ACR 的 Registry 地址
vars.ACR_IMAGE 仓库变量 应用镜像的完整仓库路径
secrets.ACR_USERNAME Secret 阿里云仓库用户名
secrets.ACR_PASSWORD Secret 阿里云仓库密码或访问凭证
secrets.GITHUB_TOKEN GitHub 提供的令牌 工作流登录 GHCR 时使用

变量与 Secret 的关键区别是:普通配置通常放在 vars,敏感凭证放在 secrets。不要把真实密码直接写进 YAML。

你的配置还包含:

1
2
3
permissions:
contents: read
packages: write

contents: read 允许工作流读取仓库内容;packages: write 允许对应的 GITHUB_TOKEN 写入 GitHub Packages,包括 GHCR 镜像发布所需的权限。阿里云 ACR 的认证则另外使用 ACR_USERNAME 和 ACR_PASSWORD。

四、 docker-ci.yml 添加详细中文注释

下面是根据你当前文件整理的完整中文注释版。我保留了原有的触发条件、构建参数、测试逻辑、镜像名称和推送规则,主要增加注释,便于你逐行学习。

有一点要注意:这是一份学习用的注释版,不是已经写回 GitHub 仓库的修改。

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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
{% raw %}
# ============================================================
# 工作流名称
# 在 GitHub 仓库的 Actions 页面中显示这个名称
# ============================================================
name: File Server CI and Publish

# ============================================================
# 触发条件:什么情况下启动 CI/CD
# ============================================================
on:
# 当代码被 push 到指定分支时触发
push:
branches:
- main # 只监听 main 分支

# 允许在 GitHub Actions 页面手动启动工作流
workflow_dispatch:

# ============================================================
# 工作流权限
# 这里设置的是 GitHub 自动提供的 GITHUB_TOKEN 权限
# ============================================================
permissions:
contents: read # 允许读取仓库代码
packages: write # 允许向 GitHub Packages / GHCR 发布镜像

# ============================================================
# 并发控制
# 防止同一组工作流同时执行
# ============================================================
concurrency:
group: file-server-publish-main # 同组工作流使用相同的并发标识
cancel-in-progress: false # 新任务不会主动取消正在运行的旧任务

# ============================================================
# Jobs:定义工作流需要执行的任务
# 当前只有一个 Job,内部包含多个按顺序执行的 Step
# ============================================================
jobs:
build-test-publish:
name: Build, test and publish # 在 Actions 页面显示的任务名称

# 使用 GitHub 托管的 Ubuntu Linux 环境
# 每次运行会分配干净的临时运行环境
runs-on: ubuntu-latest

# 整个 Job 最多运行 30 分钟,超时会被终止
timeout-minutes: 30

# ========================================================
# Steps:具体执行步骤
# 同一个 Job 内的步骤按顺序运行
# 普通步骤失败后,后续步骤默认不会继续执行
# ========================================================
steps:

# ------------------------------------------------------
# 第 1 步:下载仓库代码
# ------------------------------------------------------
- name: Checkout source
# 使用 GitHub 官方维护的 checkout Action
uses: actions/checkout@v4

# ------------------------------------------------------
# 第 2 步:检查项目文件和镜像仓库配置
# ------------------------------------------------------
- name: Validate repository and registry configuration
shell: bash

# 将 GitHub 仓库变量传入当前 Shell 步骤
env:
ACR_REGISTRY: ${{ vars.ACR_REGISTRY }}
ACR_IMAGE: ${{ vars.ACR_IMAGE }}

# 多行 Shell 脚本
run: |
# -e:命令失败时退出
# -u:使用未定义变量时退出
# -o pipefail:管道中任意命令失败,管道结果也视为失败
set -euo pipefail

# 检查构建所需的文件是否存在
# 任意文件不存在,test 就会返回非零状态
test -f app/Dockerfile
test -f app/app.py
test -f app/requirements.txt
test -f nginx/Dockerfile
test -f nginx/nginx.conf
test -f html/index.html
test -f docker-compose.yml

# 检查 ACR Registry 配置是否为空
# 为空时打印 GitHub Actions 错误信息并退出
test -n "$ACR_REGISTRY" || {
echo "::error::Set Actions variable ACR_REGISTRY first"
exit 1
}

# 检查应用镜像完整路径是否为空
test -n "$ACR_IMAGE" || {
echo "::error::Set Actions variable ACR_IMAGE first"
exit 1
}

# 检查应用镜像路径是否以 Registry 地址开头
# 例如:
# ACR_REGISTRY=registry.example.com
# ACR_IMAGE=registry.example.com/my-project/file-server
case "$ACR_IMAGE" in
"$ACR_REGISTRY"/*) ;;
*)
echo "::error::ACR_IMAGE must begin with ACR_REGISTRY/"
exit 1
;;
esac

# ------------------------------------------------------
# 第 3 步:安装并配置 Python
# ------------------------------------------------------
- name: Set up Python
uses: actions/setup-python@v5
with:
# 指定 CI 检查使用的 Python 版本
python-version: "3.12"

# ------------------------------------------------------
# 第 4 步:检查 Python 代码语法
# ------------------------------------------------------
- name: Check Python syntax
# py_compile 会检查语法,并生成字节码缓存文件
# 注意:语法检查通过不等于应用业务逻辑没有问题
run: python -m py_compile app/app.py

# ------------------------------------------------------
# 第 5 步:准备 Docker Buildx
# Buildx 是 Docker 的构建工具,支持 BuildKit 和构建缓存
# ------------------------------------------------------
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# 使用 docker-container 驱动运行 BuildKit 构建器
driver: docker-container

# ------------------------------------------------------
# 第 6 步:构建 Python 应用镜像
# 这里仅构建镜像,不推送到远程仓库
# ------------------------------------------------------
- name: Build application image
uses: docker/build-push-action@v6
with:
# 构建上下文目录
# Dockerfile 中 COPY 等指令可使用这个目录内的文件
context: ./app

# 明确指定应用使用的 Dockerfile
file: ./app/Dockerfile

# 临时镜像标签使用完整提交 SHA
# github.sha 是触发本次工作流的提交 ID
tags: file-server-app-ci:${{ github.sha }}

# 将构建结果加载到本机 Docker 镜像库
# 后面的 docker run 才能使用这个本地镜像
load: true

# 这一阶段不推送镜像
push: false

# 尝试读取之前保存的 GitHub Actions 构建缓存
cache-from: type=gha,scope=file-server-app

# 保存本次构建缓存,供以后构建复用
# mode=max 尽可能保存中间构建层
cache-to: type=gha,mode=max,scope=file-server-app

# ------------------------------------------------------
# 第 7 步:启动应用容器,执行冒烟测试
# 冒烟测试用于快速验证应用的基本功能是否可用
# ------------------------------------------------------
- name: Smoke test application container
shell: bash
run: |
# 启用严格 Shell 检查
set -euo pipefail

# 后台启动刚刚构建的应用镜像
docker run -d \
--name file-server-ci-test \
# 仅将容器的 5000 端口绑定到本机回环地址
-p 127.0.0.1:5000:5000 \
# 为测试容器注入临时环境变量
-e SECRET_KEY=ci-only-test-secret \
-e FILE_PASSWORD=ci-only-test-password \
# 使用本次提交对应的本地镜像
file-server-app-ci:${GITHUB_SHA}

# 定义清理函数,删除测试容器
cleanup() {
docker rm -f file-server-ci-test >/dev/null 2>&1 || true
}

# 当前 Shell 退出时执行清理
# 即使后面的健康检查失败,也会尝试清理容器
trap cleanup EXIT

# success=0 表示尚未检测到成功
success=0

# 最多尝试 30 次,每次间隔 2 秒
# 用来等待应用启动完成
for i in $(seq 1 30); do
# curl --fail:HTTP 错误状态返回失败
# --silent:不显示常规进度信息
if curl --fail --silent http://127.0.0.1:5000/health; then
echo
success=1
break
fi
sleep 2
done

# 30 次尝试后仍然失败,则输出容器日志并终止任务
if [ "$success" -ne 1 ]; then
docker logs file-server-ci-test
echo "::error::Container health check failed"
exit 1
fi

# ------------------------------------------------------
# 第 8 步:构建 Nginx Web 镜像
# 同样只构建、不推送,先进行配置检查
# ------------------------------------------------------
- name: Build Nginx web image
uses: docker/build-push-action@v6
with:
# Nginx 构建上下文是项目根目录
# 这样 Dockerfile 可以访问根目录下允许使用的文件
context: .

# Nginx 镜像使用自己的 Dockerfile
file: ./nginx/Dockerfile

# 使用提交 SHA 作为临时镜像标签
tags: file-server-nginx-ci:${{ github.sha }}

# 加载到本地 Docker,供后续 nginx -t 使用
load: true

# 测试阶段不发布镜像
push: false

# Nginx 专属构建缓存,避免与应用镜像缓存混用
cache-from: type=gha,scope=file-server-nginx
cache-to: type=gha,mode=max,scope=file-server-nginx

# ------------------------------------------------------
# 第 9 步:检查 Nginx 配置是否有效
# ------------------------------------------------------
- name: Validate Nginx configuration
shell: bash
run: |
set -euo pipefail

# 在临时目录创建证书存放位置
mkdir -p "$RUNNER_TEMP/nginx-certs"

# 生成仅用于 CI 检查的自签名证书
# -x509:生成自签名证书
# -nodes:不使用密码加密私钥
# -newkey rsa:2048:创建 2048 位 RSA 密钥
# -days 1:证书有效期为 1 天
# -subj:指定证书主题,避免交互式提问
openssl req -x509 -nodes -newkey rsa:2048 \
-keyout "$RUNNER_TEMP/nginx-certs/server.key" \
-out "$RUNNER_TEMP/nginx-certs/server.crt" \
-days 1 -subj "/CN=localhost"

# 启动一个临时容器,只执行 Nginx 配置检查
docker run --rm \
# 让容器内的 app 主机名解析到 127.0.0.1
--add-host app:127.0.0.1 \
# 将临时证书目录以只读方式挂载到容器
-v "$RUNNER_TEMP/nginx-certs:/etc/nginx/certs:ro" \
# 使用刚刚构建的镜像执行 nginx -t
file-server-nginx-ci:${GITHUB_SHA} nginx -t

# ------------------------------------------------------
# 第 10 步:生成镜像标签
# 生成北京时间时间戳标签和短提交 SHA 标签
# ------------------------------------------------------
- name: Generate readable China-time tags
id: image-tags
shell: bash
run: |
set -euo pipefail

# 使用上海时区生成时间戳
# 例如:20261009-184500
TAG="$(TZ=Asia/Shanghai date +'%Y%m%d-%H%M%S')"

# 截取完整提交 SHA 的前 7 个字符
SHORT_SHA="${GITHUB_SHA:0:7}"

# 写入 GitHub Actions 的步骤输出文件
# 后续步骤可以通过 steps.image-tags.outputs 读取
echo "timestamp=$TAG" >> "$GITHUB_OUTPUT"
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"

# 在日志中打印生成的时间标签
echo "Image timestamp tag: $TAG"

# ------------------------------------------------------
# 第 11 步:登录阿里云 ACR
# 使用 GitHub 仓库变量和 Secret 提供认证信息
# ------------------------------------------------------
- name: Login to Alibaba Cloud ACR
uses: docker/login-action@v3
with:
# ACR 的 Registry 地址
registry: ${{ vars.ACR_REGISTRY }}

# 从 GitHub Secrets 读取用户名和密码
username: ${{ secrets.ACR_USERNAME }}
password: ${{ secrets.ACR_PASSWORD }}

# ------------------------------------------------------
# 第 12 步:登录 GitHub Container Registry
# GHCR 的域名是 ghcr.io
# ------------------------------------------------------
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io

# 当前触发工作流的 GitHub 用户或应用身份
username: ${{ github.actor }}

# 使用 GitHub 自动提供的令牌认证
password: ${{ secrets.GITHUB_TOKEN }}

# ------------------------------------------------------
# 第 13 步:给两个镜像打标签,并发布到两个仓库
# 到这里,前面的构建和测试都已成功完成
# ------------------------------------------------------
- name: Tag and publish both images to both registries
shell: bash

# 将仓库地址和之前生成的标签输出传给 Shell
env:
# ACR 应用镜像完整路径
ACR_IMAGE: ${{ vars.ACR_IMAGE }}

# 使用当前 GitHub 仓库自动生成 GHCR 镜像路径
# 例如 ghcr.io/owner/repository
GHCR_IMAGE: ghcr.io/${{ github.repository }}

# 引用第 10 步生成的时间戳和短 SHA
TIMESTAMP_TAG: ${{ steps.image-tags.outputs.timestamp }}
SHORT_SHA: ${{ steps.image-tags.outputs.short_sha }}

run: |
set -euo pipefail

# 定义可重复使用的镜像发布函数
# 参数 1:本地已有的源镜像
# 参数 2:需要推送到的目标镜像路径
publish_image() {
local SOURCE="$1"
local IMAGE="$2"

# 同一份镜像生成三个标签
for TAG in "$TIMESTAMP_TAG" "$SHORT_SHA" latest; do

# 将源镜像标记为目标仓库中的指定版本
docker tag "$SOURCE" "${IMAGE}:$TAG"

# 将带标签的镜像推送到远程仓库
docker push "${IMAGE}:$TAG"
done

# 打印发布结果
echo "Published image: $IMAGE (tags: $TIMESTAMP_TAG, $SHORT_SHA, latest)"
}

# 发布应用镜像到阿里云 ACR
publish_image "file-server-app-ci:${GITHUB_SHA}" "$ACR_IMAGE"

# 发布 Nginx 镜像到 ACR
# 通过 -nginx 区分应用镜像与 Web 镜像
publish_image "file-server-nginx-ci:${GITHUB_SHA}" "${ACR_IMAGE}-nginx"

# 发布应用镜像到 GHCR
publish_image "file-server-app-ci:${GITHUB_SHA}" "$GHCR_IMAGE"

# 发布 Nginx 镜像到 GHCR
publish_image "file-server-nginx-ci:${GITHUB_SHA}" "${GHCR_IMAGE}-nginx"

# --------------------------------------------------
# 将发布摘要写入 GitHub Actions 的运行摘要
# GitHub 会在本次工作流的 Summary 页面显示这些内容
# --------------------------------------------------
{
echo "### Published Docker images"
echo
echo "Tags: $TIMESTAMP_TAG, $SHORT_SHA, latest"
echo
echo "ACR app: $ACR_IMAGE"
echo "ACR web: ${ACR_IMAGE}-nginx"
echo "GHCR app: $GHCR_IMAGE"
echo "GHCR web: ${GHCR_IMAGE}-nginx"
} >> "$GITHUB_STEP_SUMMARY"
{% endraw %}

五、这份工作流中最值得理解的 5 个细节

1. 为什么先构建,再推送?

你的两个构建步骤都设置了:

1
2
load: true
push: false

这表示先将构建结果加载到本地 Docker 镜像库,不立即发布到远程仓库。接下来启动容器或检查 Nginx 配置,验证成功后才登录仓库并推送。

这是一个很实用的原则:先验证,再发布。

2. 为什么使用三个标签?

你的发布函数会给每个镜像创建三个标签。

标签 示例 用途
时间戳 20261009-184500 方便按发布时间查找版本
短 SHA a1b2c3d 方便追溯对应的代码提交
latest latest 表示当前发布的最新版本

实际时间戳和 SHA 会根据每次运行动态生成。

需要注意,latest 会被新版本覆盖,所以生产环境如果需要稳定回滚,最好使用时间戳、提交 SHA 或镜像摘要,而不是只依赖 latest。

3. GITHUB_OUTPUT 是什么?

你的代码中有:

1
2
echo "timestamp=$TAG" >> "$GITHUB_OUTPUT"
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"

它将当前步骤生成的数据传递给后面的步骤。后面通过:

1
2
3
4
{% raw %}
TIMESTAMP_TAG: ${{ steps.image-tags.outputs.timestamp }}
SHORT_SHA: ${{ steps.image-tags.outputs.short_sha }}
{% endraw %}

读取这些值。

其中 image-tags 是步骤的 id,timestamp 和 short_sha 是步骤输出的名称。

这是一种很常见的工作流数据传递方式。

4. set -euo pipefail 为什么常见?

这条命令让 Shell 脚本更加严格:

  • -e:大多数未被处理的命令失败会导致脚本退出。
  • -u:使用未定义变量时退出。
  • pipefail:管道中的命令失败不会轻易被后续成功命令掩盖。

它可以避免脚本在某个关键操作失败后,仍然继续推送镜像。

不过,Shell 的错误处理有一些例外情况,例如 if 条件中执行的命令、使用 || 处理的失败等,因此它不能替代明确的错误判断。

5. 冒烟测试不等于完整测试

你当前的 Python 检查:

1
python -m py_compile app/app.py

主要验证语法是否正确。

而 /health 检查主要验证容器是否能够启动并返回成功响应。它不能证明登录、文件上传、分片合并、下载、权限控制等业务功能都正确。