GitHub Actions 自动构建 Docker 镜像完全指南:从手动触发到定时检查与版本追踪
在开源项目镜像分发、企业内部基础镜像维护、多环境构建等场景中,如何让「上游源码变更 → 自动构建 → 推送镜像」这条链路既自动又可控,是一个值得认真设计的问题。本文以一个真实项目 daitcl/Microsoft-Rewards-Script 为例,从最朴素的手动构建出发,逐步演进到「定时检查 + 无状态判断 + 语义化标签」的完整方案,并深入其设计原理与踩坑记录。
1. 最朴素的做法:手动触发构建
最初的方案非常直接——在仓库里放一个 workflow,配好 workflow_dispatch,需要更新时进 Actions 页面点一下 “Run workflow”:
name: Build and Push Docker Image
on: workflow_dispatch: inputs: tag: description: '镜像标签' required: false default: 'latest' type: string
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: repository: chiihero/Microsoft-Rewards-Script ref: V4-china path: source - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: ./source file: ./source/Dockerfile push: true tags: ghcr.io/${{ github.repository_owner }}/microsoft-rewards-script:${{ inputs.tag }}对于个人项目、更新不频繁的场景,这已经够用了。但运行一段时间后会出现问题:
- 上游更新了不知道:需要人工去上游仓库看
package.json的version有没有变。 - 无法追溯源码:镜像上只有
latest,出问题时无法定位到对应哪个 commit。 - 重复劳动:每次都要手动点,容易忘记或手抖。
latest漂移:容器重建后行为可能变了,排查困难。
2. 改进:让标签携带语义信息
最直接的改进是一次构建,打多个标签。上游 package.json 里有精确版本号,git 有 commit SHA,把这两个信息都反映到镜像标签上:
| 标签 | 说明 | 示例 |
|---|---|---|
latest | 最新一次成功构建 | latest |
<version> | 上游 package.json 精确版本号 | 4.3.2.3 |
v<version> | 带 v 前缀,符合语义化习惯 | v4.3.2.3 |
sha-<short> | 对应上游源码提交短 SHA,便于追溯 | sha-a1b2c3d |
读取 package.json 版本号:这一步不需要 jq 或 grep,用 Node 自带的 JSON 解析最稳:
VERSION=$(node -p "require('./source/package.json').version")写入多个标签:在 build-push-action 的 tags: 里逐行列出即可:
tags: | ${{ env.IMAGE_REF }}:latest ${{ env.IMAGE_REF }}:${{ steps.pkg.outputs.version }} ${{ env.IMAGE_REF }}:v${{ steps.pkg.outputs.version }} ${{ env.IMAGE_REF }}:sha-${{ steps.source_sha.outputs.sha }}其中
IMAGE_REF需在 workflow 的env:中定义为ghcr.io/<owner>/microsoft-rewards-script。
3. 定时检查:从”人找版本”到”版本找人”
第 2 节的标签方案虽然丰富了元信息,但触发方式依然是手动的。
3.1 思路一:维护状态文件(不推荐)
最直观的做法是:每次构建完,把当前版本号写到一个文件里,下次定时任务对比文件与上游 package.json。
- name: 对比版本 run: | OLD=$(cat .last_built_version) NEW=$(node -p "require('./source/package.json').version") if [ "$OLD" != "$NEW" ]; then echo "should_build=true" >> "$GITHUB_OUTPUT" fi问题:状态存储不可靠。
| 存储方式 | 缺陷 |
|---|---|
| Actions Cache | 会被驱逐(7 天不访问即删) |
| Artifact | 默认 90 天过期 |
| 提交回仓库 | 污染提交历史,可能触发递归 workflow |
| GitHub Variables | 写权限粒度粗,更新有延迟 |
3.2 思路二:以镜像存在性为判断依据(推荐)
换一个角度看问题:“是否需要构建”等价于”目标标签的镜像是否已存在”。
构建工作流本来就往 GHCR 推 v<version> 标签,那就用它做判断:
读取 package.json 版本 → 查 GHCR 上是否已有 v<version> ├── 有 → 跳过构建 └── 无 → 触发构建这个判断无状态、幂等、可靠。不维护任何额外文件,反复运行结果一致,失败了下次定时任务自然重试。
判断逻辑:
should_build = !exists(ghcr.io/owner/image:v${VERSION})
查询镜像是否存在,有两种方式:
方式 A:用 GitHub API(更快)
gh api "/users/${OWNER}/packages/container/microsoft-rewards-script/versions" \ -H "Accept: application/vnd.github+json" \ | grep -q "v${VERSION}"方式 B:用 docker manifest inspect(更通用)
docker login ghcr.io -u "${{ github.actor }}" --password-stdin <<< "${{ secrets.GITHUB_TOKEN }}"docker manifest inspect "ghcr.io/${OWNER}/microsoft-rewards-script:v${VERSION}" >/dev/null 2>&1个人账号用 /users/<owner>/...,组织账号用 /orgs/<owner>/...,两者都试一下最保险。
4. 职责分离:引入 Reusable Workflow
第 3 节的检查逻辑如果塞进 build.yml,会让它同时承担”检查”和”构建”两个职责,
将来想复用构建就得复制粘贴。更干净的做法是拆成两个:
| 工作流 | 职责 | 触发方式 |
|---|---|---|
check-version.yml | 读取上游版本,判断是否需要构建 | schedule + workflow_dispatch |
build.yml | 实际构建并推送镜像 | workflow_call + workflow_dispatch |
build.yml 声明 workflow_call,就变成一个 reusable workflow:
on: workflow_dispatch: inputs: tag: required: false default: 'latest' type: string workflow_call: inputs: tag: required: false type: string default: latestcheck-version.yml 通过 uses 调用它:
jobs: check: runs-on: ubuntu-latest outputs: should_build: ${{ steps.check.outputs.should_build }} steps: # ... 读取版本、查询镜像 ...
build: needs: check if: needs.check.outputs.should_build == 'true' permissions: contents: read packages: write uses: ./.github/workflows/build.yml with: tag: latest调用方式:
uses: ./.github/workflows/build.yml
5. Reusable Workflow 的三条硬性约束
这三条约束任意一条不满足都会直接报错,且错误信息往往指向不明显的地方。
5.1 被调用的 workflow 必须在默认分支上
GitHub 硬性要求:uses: ./.github/workflows/xxx.yml 引用的文件必须存在于仓库的默认分支。
即使你在调用方写了 @V4-china,对本地路径引用也无效。后果是:
Unable to find reusable workflow解决方案:把 build.yml 和 check-version.yml 都合并到 main 分支。
5.2 调用 job 不能有 runs-on / steps
uses 类型的 job 和普通 job 结构不同:
# ✅ 正确build: needs: check if: needs.check.outputs.should_build == 'true' permissions: packages: write uses: ./.github/workflows/build.yml with: tag: latest
# ❌ 错误:不能有 runs-on 和 stepsbuild: runs-on: ubuntu-latest # 报错 # steps: ... # 报错 uses: ./.github/workflows/build.yml5.3 权限不能自动提升
Reusable workflow 拿到的权限是调用方授予的子集。如果 build.yml 需要 packages: write,调用方 job 必须显式声明:
build: permissions: contents: read packages: write # 必须有,否则 build.yml 推镜像会失败 uses: ./.github/workflows/build.yml6. 完整实现
6.1 check-version.yml
name: Check Version & Trigger Build
on: schedule: - cron: '0 3 * * *' # UTC 03:00 = 北京 11:00 workflow_dispatch:
concurrency: group: check-version cancel-in-progress: false
jobs: check: name: 检查 package.json 版本 runs-on: ubuntu-latest permissions: contents: read packages: read outputs: version: ${{ steps.pkg.outputs.version }} should_build: ${{ steps.check.outputs.should_build }}
steps: - name: 仅拉取 package.json uses: actions/checkout@v4 with: repository: chiihero/Microsoft-Rewards-Script ref: V4-china path: source sparse-checkout: | package.json sparse-checkout-cone-mode: false
- name: 读取版本号 id: pkg shell: bash run: | VERSION=$(node -p "require('./source/package.json').version") echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "Package version: ${VERSION}"
- name: 检查 GHCR 是否已存在 id: check shell: bash env: VERSION: ${{ steps.pkg.outputs.version }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | OWNER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]') PKG_ORGS="/orgs/${OWNER}/packages/container/microsoft-rewards-script/versions" PKG_USERS="/users/${OWNER}/packages/container/microsoft-rewards-script/versions"
# 个人账号走 /users/,组织账号走 /orgs/,两者都试 if gh api "$PKG_ORGS" -H "Accept: application/vnd.github+json" 2>/dev/null \ | grep -q "v${VERSION}" \ || gh api "$PKG_USERS" -H "Accept: application/vnd.github+json" 2>/dev/null \ | grep -q "v${VERSION}"; then echo "should_build=false" >> "$GITHUB_OUTPUT" echo "✅ 镜像 v${VERSION} 已存在,跳过构建" else echo "should_build=true" >> "$GITHUB_OUTPUT" echo "🆕 镜像 v${VERSION} 不存在,触发构建" fi
build: name: 调用构建工作流 needs: check if: needs.check.outputs.should_build == 'true' permissions: contents: read packages: write uses: ./.github/workflows/build.yml with: tag: latest关键点说明:
node -p直接调用 Node 的 JSON 解析,比grep/sed更稳,能正确处理格式变化。- 若
gh api因网络或权限临时失败,两个路径都会返回空,被判定为”镜像不存在”从而触发构建。这是刻意设计——宁可多构建一次,也不漏检版本。
6.2 build.yml
name: Build and Push Docker Image
on: workflow_dispatch: inputs: tag: description: '镜像标签' required: false default: 'latest' type: string workflow_call: inputs: tag: required: false type: string default: latest
jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write
steps: - name: Checkout V4-china 分支源码 uses: actions/checkout@v4 with: repository: chiihero/Microsoft-Rewards-Script ref: V4-china path: source
- name: 获取源码提交短 SHA id: source_sha run: echo "sha=$(git -C source rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: 读取 package.json 版本号 id: pkg run: | VERSION=$(node -p "require('./source/package.json').version") echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: 设置 Docker Buildx uses: docker/setup-buildx-action@v3
- name: 登录 GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: 生成镜像引用 id: image run: | OWNER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]') echo "ref=ghcr.io/${OWNER}/microsoft-rewards-script" >> "$GITHUB_OUTPUT"
- name: 构建并推送 uses: docker/build-push-action@v6 with: context: ./source file: ./source/Dockerfile push: true tags: | ${{ steps.image.outputs.ref }}:latest ${{ steps.image.outputs.ref }}:${{ steps.pkg.outputs.version }} ${{ steps.image.outputs.ref }}:v${{ steps.pkg.outputs.version }} ${{ steps.image.outputs.ref }}:sha-${{ steps.source_sha.outputs.sha }} labels: | org.opencontainers.image.source=https://github.com/chiihero/Microsoft-Rewards-Script org.opencontainers.image.revision=${{ steps.source_sha.outputs.sha }} org.opencontainers.image.version=${{ steps.pkg.outputs.version }}7. 徽章显示
reusable workflow 不产生独立运行记录——workflow_call 触发的运行只在调用方的运行里显示为嵌套 job。这意味着:
- ❌ 无法用
build.yml自己的状态做徽章,会永远显示no status - ✅ 应该用调用方
check-version.yml的状态做徽章
<a href="https://github.com/USER/REPO/actions/workflows/check-version.yml"> <img src="https://img.shields.io/github/actions/workflow/status/USER/REPO/check-version.yml?branch=main&label=auto-build" alt="自动构建状态"></a>徽章地址:
.../check-version.yml?branch=main&label=auto-build
?branch=main 显式指定默认分支,避免 shields.io 找不到运行记录而显示 no status。
8. GPL 合规:分发镜像的注意事项
如果被构建的上游源码是 GPL 许可(本项目是 GPL-3.0-or-later),分发镜像时需注意合规。
| 行为 | GPL 术语 | 是否需要授权 |
|---|---|---|
| clone 源码到 Actions | 无 | ❌ 不需要 |
| 在 CI 里编译 | 修改/运行 | ❌ 不需要 |
| 推送镜像到 GHCR | convey(传播) | ✅ 需要,GPL 已授予 |
| 用户 pull 镜像 | 接收 | ✅ 自动获得授权 |
GPL v3 §6 要求:分发 object code(镜像)时,必须同时提供对应源码。满足方式:
- 上游源码公开可访问
- 镜像标签
sha-<short>让对应源码可精确定位 - 镜像 OCI label 写入
org.opencontainers.image.source和org.opencontainers.image.revision
必须避免的坑:
- ❌ 加”禁止商业使用”之类的额外限制(GPL v3 §10 禁止)
- ❌ 写”仅供教育目的”——容易被误读为使用限制
- ✅ 保留所有版权声明和
LICENSE文件 - ✅ 在 README 明确源码位置
9. 使用示例
用户只需三步:
# 1. 准备 .envcp env.example .env# 编辑 .env 填入账号
# 2. 启动docker compose up -d
# 3. 更新(自动构建完成后)docker compose pulldocker compose up -d生产环境建议固定到具体版本,避免 latest 漂移:
image: ghcr.io/daitcl/microsoft-rewards-script:v4.3.2.310. 总结
本文从最简单的 workflow_dispatch 手动构建出发,逐步演进到「定时检查 + 无状态判断 + 语义化标签 + reusable workflow」的完整方案。
实现要点回顾:
- 无状态判断:以 GHCR 镜像存在性为判断依据,不维护任何额外状态文件。
- 职责分离:检查与构建拆成两个 workflow,通过
workflow_call组合。 - 语义化标签:同时提供
latest、精确版本、v前缀、源码 SHA 四个标签。 - Reusable workflow 三约束:文件必须在默认分支、调用 job 不能有
runs-on/steps、权限需调用方显式授予。 - 徽章用调用方:
workflow_call不产生独立 run,徽章应指向check-version.yml。 - GPL 合规:提供对应源码位置、避免额外限制、保留版权声明。
注意事项:
schedule的 cron 是 UTC 时间,换算成本地时间需注意时差。concurrency防止定时任务重叠,避免重复构建。sparse-checkout只拉package.json可显著缩短检查耗时。
掌握这套模式后,任何「上游源码 + 自动构建 + 分发镜像」类项目都可以套用,只需替换仓库地址和构建上下文即可。
10.1 完整配置分享
- 仓库地址:daitcl/Microsoft-Rewards-Script
- 镜像地址:
ghcr.io/daitcl/microsoft-rewards-script:latest - GHCR Package:https://github.com/daitcl/Microsoft-Rewards-Script/pkgs/container/microsoft-rewards-script