git tag 推上去,镜像自动进 GHCR:多架构构建、semver 标签,以及 latest 被 prerelease 顶掉的坑
目标很明确:打一个 v2.1.0 的 tag,CI 自动构建 amd64/arm64 镜像、推到 GHCR、打上 2.1.0 / 2.1 / 2 / latest 一整套标签。下面是一条能直接抄的 workflow,以及几个实际踩过的取舍。
触发与 permissions
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: write # 建 GitHub Release 时需要
packages: write # 推 ghcr.io 必需
只有打 tag(或在 GitHub 上发 Release)才真正发布镜像。push 到 main/dev 通常只推 edge/dev 这类 tag 用于验证,不抢 latest。
GHCR 凭据:不需要额外 secret
GHCR 用 workflow 自带的 GITHUB_TOKEN 就能登录推送,只要 permissions 里给了 packages: write:
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
首次推完之后,要去 package 设置里把包改成 Public 并 link 到仓库,匿名 docker pull 才拉得动。GHCR 本身不做匿名拉取限速,仓库也在旁边,链路短。
想同时推 Docker Hub,用条件开关:仅当配了 vars.DOCKERHUB_USERNAME 才 login,token 放 secrets.DOCKERHUB_TOKEN,metadata-action 的 images 多行分别写两个 registry 即可。
语义化标签派生
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
结果:v2.0.0 → 2.0.0 / 2.0 / 2 / latest;v2.0.0-rc1(ref 里含 -)→ 只有 2.0.0-rc1,不碰 latest。
这里的坑在于:latest 必须加 predicate。不给 enable 条件的话,prerelease 会静默把 latest 往下指,所有 docker pull :latest 的消费者就被带回旧版或预发布版。另外 metadata-action 默认不生成 :latest,得显式写 type=raw。
多架构:QEMU 模拟还是原生 runner
(a) 单 job + QEMU,简单,一个 job 搞定:
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
代价是 arm64 走 QEMU 模拟,大约 5–10 分钟;amd64 原生大概 2 分钟。镜像越大差距越明显。
(b) 分架构原生 runner + merge,快,但多一个 job。matrix 里 amd64 用 ubuntu-latest,arm64 用 ubuntu-24.04-arm,都不走 QEMU:
build:
strategy:
matrix:
include:
- { name: amd64, runner: ubuntu-latest, platform: linux/amd64 }
- { name: arm64, runner: ubuntu-24.04-arm, platform: linux/arm64 }
runs-on: ${{ matrix.runner }}
每个架构只推单个 digest:
- id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
outputs: type=image,name=ghcr.io/OWNER/IMG,push-by-digest=true,name-canonical=true,push=true
provenance: false # 关键
build-args: |
APP_VERSION=${{ github.ref_name }}
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}
provenance: false 不能省。默认开启的 provenance 会额外包一层 attestation index,最终 manifest 里会出现 unknown/unknown 条目,一些老客户端和镜像扫描工具会识别不了。
digest 写文件后上传:
- run: |
mkdir -p /tmp/digests
touch "/tmp/digests/${ { steps.build.outputs.digest }#sha256:}"
- uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.name }}
path: /tmp/digests/*
if-no-files-found: error
merge job 收齐再拼 manifest:
- uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- run: |
cd /tmp/digests
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/OWNER/IMG@sha256:%s ' *)
- run: docker buildx imagetools inspect ghcr.io/OWNER/IMG:${{ steps.meta.outputs.version }}
merge job 里同样要 setup-buildx-action 和 login-action,以及再跑一遍 metadata-action(标签列表由它输出到 DOCKER_METADATA_OUTPUT_JSON)。
版本号烤进镜像
build-args: |
APP_VERSION=${{ github.ref_name }}
程序读这个环境/编译期常量,/api/version 或 UI 就能显示出来。手动触发时可从 VERSION 文件读,或退化成 dev-<短SHA>:
if [ "${{ github.ref_type }}" = "tag" ]; then
echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
else
echo "version=dev-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi
缓存
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}
多镜像、多架构都要用 scope 分开,否则 amd64 和 arm64 的缓存互相覆盖,命中率反而掉下去。
Release 与 notes
softprops/action-gh-release 或 actions/github-script 都可以,generate_release_notes: true 会让 GitHub 根据上一个 tag 到 HEAD 之间的 PR 自动生成 notes;commit 遵循 feat: / fix: 前缀,分组会更可读。这一步需要 permissions.contents: write。该 action 有写权限,建议 SHA pin,blast radius 比较大。
兼容旧引用
有的项目同时打 tag=ref(保留 v 前缀)和 type=semver,pattern={{version}}(不带 v),这样用户既能 docker pull IMG:v1.0.0 也能 IMG:1.0.0。两条都留,代价只是多几个标签。