# ============================================================================
# GitHub Actions Workflow: 自动发布容器镜像到 GHCR
# ============================================================================
#
# @description 自动构建多架构 Docker 镜像并发布到 GitHub Container Registry
# @author DevOps Team
# @created 2025-12-30
# @updated 2025-12-30
#
# @triggers
# - push:
# tags: ['v*.*.*'] # 语义化版本标签触发生产发布
# - workflow_dispatch: # 手动触发(用于紧急修复)
# - schedule: # 定时构建(每周日重建基础镜像)
#
# @outputs
# - 镜像地址: ghcr.io/${{ github.repository }}:latest
# - 支持架构: linux/amd64, linux/arm64
# - 附加产物: SBOM, 漏洞扫描报告
#
# @permissions
# - contents: read # 读取仓库代码
# - packages: write # 推送到 GHCR
# - id-token: write # 用于 OIDC 认证(可选)
#
# @dependencies
# - docker/setup-buildx-action@v3
# - docker/login-action@v3
# - docker/metadata-action@v5
# - docker/build-push-action@v5
#
# ============================================================================
name: "🚀 Publish Container to GHCR"
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
release_version:
description: "手动指定发布版本(可选,示例:v1.2.3)"
required: false
type: string
push_latest:
description: "是否同时推送 latest 标签"
default: true
required: false
type: boolean
enable_cosign:
description: "启用 Cosign 镜像签名(需要 OIDC 或密钥)"
default: false
required: false
type: boolean
schedule:
- cron: "0 3 * * 0" # 每周日 03:00 UTC 重建基础镜像
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: false # 生产发布不强制取消,避免发布中断
permissions:
contents: read
packages: write
id-token: write
env:
REGISTRY: ghcr.io
BUILD_PLATFORMS: "linux/amd64,linux/arm64"
CACHE_REGISTRY: "ghcr.io/${{ github.repository }}:buildcache"
DOCKERFILE: "Dockerfile.example"
BUILDX_INSTANCE: "gha-multiarch-builder"
SBOM_PATH: "artifacts/sbom.json"
MANIFEST_REPORT: "artifacts/manifest.txt"
SMOKE_REPORT: "artifacts/smoke-tests.txt"
VALIDATION_TAG: "ghcr.io/${{ github.repository }}:${{ github.sha }}"
ENABLE_COSIGN_SIGNING: "${{ inputs.enable_cosign || 'false' }}"
PUSH_LATEST: "${{ inputs.push_latest || 'true' }}"
jobs:
build-and-push:
name: "📦 Multi-Arch Build & Publish"
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: "📥 Checkout repository"
# ----------------------------------------------------------------
# 功能说明:获取代码仓库内容,确保包含全部历史以便 metadata 解析标签
# 输入:GITHUB_TOKEN(自动提供)
# 输出:工作目录代码
# 错误处理:若检出失败,后续步骤无法执行
# ----------------------------------------------------------------
uses: actions/checkout@v4.1.7
with:
fetch-depth: 0
- name: "🧭 Determine release ref"
# ----------------------------------------------------------------
# 功能说明:统一发布引用,手动触发时允许覆盖版本号
# 输出:环境变量 RESOLVED_REF/RESOLVED_VERSION
# 错误处理:非法版本格式会中止发布
# ----------------------------------------------------------------
id: release_ref
run: |
set -eo pipefail
SEMVER_REGEX='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
REF_NAME="${GITHUB_REF_NAME}"
if [ -n "${{ inputs.release_version || '' }}" ]; then
REF_NAME="${{ inputs.release_version }}"
fi
if ! echo "${REF_NAME}" | grep -Eq "${SEMVER_REGEX}"; then
echo "::warning::未提供有效语义化版本,使用当前引用 ${REF_NAME}"
fi
echo "resolved_ref=${REF_NAME}" >> "$GITHUB_OUTPUT"
- name: "🔧 Resolve latest tag gate"
# ----------------------------------------------------------------
# 功能说明:集中计算 latest 标签是否应启用,便于审计与维护
# ----------------------------------------------------------------
id: tag_gate
run: |
set -euo pipefail
enable="false"
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == refs/tags/* ]]; then
enable="true"
elif [[ "${GITHUB_EVENT_NAME}" == "schedule" ]]; then
enable="true"
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
case "${{ inputs.push_latest || 'true' }}" in
true|TRUE|1|"") enable="true" ;;
*) enable="false" ;;
esac
fi
echo "enable_latest=${enable}" >> "$GITHUB_OUTPUT"
- name: "🛠️ Setup QEMU for emulation"
# ----------------------------------------------------------------
# 功能说明:安装 QEMU 以支持跨架构构建(arm64)
# 输入:无需额外输入
# 输出:可用的 QEMU 环境
# 错误处理:安装失败将阻断后续多架构构建
# ----------------------------------------------------------------
uses: docker/setup-qemu-action@v3.2.0
with:
image: tonistiigi/binfmt:latest
platforms: all
- name: "🏗️ Setup Docker Buildx"
# ----------------------------------------------------------------
# 功能说明:创建隔离的 buildx builder,启用多架构与缓存
# 输入:BUILDX_INSTANCE
# 输出:可用的 buildx builder
# 错误处理:创建失败后续构建无法进行
# ----------------------------------------------------------------
id: buildx
uses: docker/setup-buildx-action@v3.7.1
with:
install: true
driver: docker-container
buildkitd-flags: "--debug"
use: true
name: "${{ env.BUILDX_INSTANCE }}"
- name: "🗄️ Restore buildx cache"
# ----------------------------------------------------------------
# 功能说明:恢复 GHA 层缓存以缩短构建时间
# 输入:cache key 绑定 workflow + ref
# 输出:本地缓存目录
# 错误处理:缓存缺失仅影响性能不影响功能
# ----------------------------------------------------------------
uses: actions/cache@v4.0.2
with:
path: /tmp/.buildx-cache
key: buildx-${{ github.workflow }}-${{ github.ref_name }}-${{ hashFiles(env.DOCKERFILE, '**/pyproject.toml') }}
restore-keys: |
buildx-${{ github.workflow }}-${{ github.ref_name }}-
buildx-${{ github.workflow }}-
- name: "🔑 Login to GHCR"
# ----------------------------------------------------------------
# 功能说明:使用 GitHub Token 登录 GHCR
# 输入:GITHUB_TOKEN(packages:write 权限)
# 输出:已验证的 Docker login session
# 错误处理:认证失败将阻止推送镜像
# ----------------------------------------------------------------
uses: docker/login-action@v3.3.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: "📦 Extract metadata (tags, labels)"
# ----------------------------------------------------------------
# 功能说明:从 Git 引用中提取语义化版本并生成 OCI 标签
#
# 输入:
# - github.ref: refs/tags/v1.2.3
# 输出:
# - tags: latest, v1.2.3, v1.2, v1, commit SHA
# - labels: org.opencontainers.image.*
#
# 错误处理:
# - 非法标签格式将回退到 commit SHA
# ----------------------------------------------------------------
id: meta
uses: docker/metadata-action@v5.5.1
with:
images: |
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable=${{ steps.tag_gate.outputs.enable_latest == 'true' }}
type=raw,value=${{ github.sha }}
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
type=sha,format=long
labels: |
org.opencontainers.image.title=${{ github.repository }}
org.opencontainers.image.description=Pandoc MCP container image with multi-arch support
org.opencontainers.image.url=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.licenses=MIT
- name: "🧮 Render tag summary"
# ----------------------------------------------------------------
# 功能说明:列出实际将要推送的标签,便于审计
# 输出:human readable summary
# ----------------------------------------------------------------
run: |
echo "Tags to be published:"
printf '%s\n' "${{ steps.meta.outputs.tags }}"
- name: "🔐 Optional: Install Cosign"
# ----------------------------------------------------------------
# 功能说明:在需要签名时安装 cosign(使用 OIDC 或密钥)
# 条件:ENABLE_COSIGN_SIGNING == 'true'
# ----------------------------------------------------------------
if: env.ENABLE_COSIGN_SIGNING == 'true'
uses: sigstore/cosign-installer@v3.5.0
with:
cosign-release: "v2.4.0"
- name: "🏗️ Build & Push multi-arch image"
# ----------------------------------------------------------------
# 功能说明:使用 buildx 构建并推送多架构镜像,启用三层缓存
# 缓存:
# - Registry cache: ${{ env.CACHE_REGISTRY }}
# - GitHub Actions cache: actions/cache 目录
# - 本地 buildx cache: /tmp/.buildx-cache
# ----------------------------------------------------------------
id: build_push
uses: docker/build-push-action@v5.3.0
with:
context: .
file: ${{ env.DOCKERFILE }}
pull: true
push: true
provenance: false
sbom: true
platforms: ${{ env.BUILD_PLATFORMS }}
builder: ${{ steps.buildx.outputs.name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: |
type=gha,scope=${{ github.workflow }}-${{ github.ref_name }}
type=registry,ref=${{ env.CACHE_REGISTRY }}
type=local,src=/tmp/.buildx-cache
cache-to: |
type=gha,mode=max,scope=${{ github.workflow }}-${{ github.ref_name }}
type=registry,ref=${{ env.CACHE_REGISTRY }},mode=max
type=local,dest=/tmp/.buildx-cache-new,mode=max
outputs: type=image,push=true
- name: "🔄 Move local cache"
# ----------------------------------------------------------------
# 功能说明:将新缓存目录覆盖旧目录,保持缓存可复用
# ----------------------------------------------------------------
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
- name: "🛡️ Cosign sign image"
# ----------------------------------------------------------------
# 功能说明:当启用时对镜像进行签名(示例使用 OIDC)
# 条件:ENABLE_COSIGN_SIGNING == 'true'
# ----------------------------------------------------------------
if: env.ENABLE_COSIGN_SIGNING == 'true'
env:
COSIGN_EXPERIMENTAL: "1"
run: |
set -euo pipefail
TARGET_IMAGE="ghcr.io/${{ github.repository }}@${{ steps.build_push.outputs.digest }}"
cosign sign --yes "${TARGET_IMAGE}"
- name: "🧾 Inspect multi-arch manifest"
# ----------------------------------------------------------------
# 功能说明:验证多架构 manifest 是否包含期望架构
# 输出:manifest 报告写入 $MANIFEST_REPORT
# ----------------------------------------------------------------
run: |
mkdir -p "$(dirname "${MANIFEST_REPORT}")"
docker buildx imagetools inspect "${VALIDATION_TAG}" | tee "${MANIFEST_REPORT}"
- name: "🚦 Smoke test (linux/amd64)"
# ----------------------------------------------------------------
# 功能说明:拉取并运行 amd64 镜像进行冒烟测试
# ----------------------------------------------------------------
run: |
docker pull --platform linux/amd64 "${VALIDATION_TAG}"
docker run --rm --platform linux/amd64 "${VALIDATION_TAG}" --help | head -n 20 | tee -a "${SMOKE_REPORT}"
- name: "🚦 Smoke test (linux/arm64 via QEMU)"
# ----------------------------------------------------------------
# 功能说明:拉取并运行 arm64 镜像进行冒烟测试(依赖 QEMU)
# ----------------------------------------------------------------
run: |
docker pull --platform linux/arm64 "${VALIDATION_TAG}"
docker run --rm --platform linux/arm64 "${VALIDATION_TAG}" --help | head -n 20 | tee -a "${SMOKE_REPORT}"
- name: "📤 Upload reports"
# ----------------------------------------------------------------
# 功能说明:上传 manifest 与 smoke test 报告用于审计
# ----------------------------------------------------------------
uses: actions/upload-artifact@v4.4.0
with:
name: publish-reports
path: |
${{ env.MANIFEST_REPORT }}
${{ env.SMOKE_REPORT }}
- name: "🧹 Cleanup builder"
# ----------------------------------------------------------------
# 功能说明:清理构建器与悬挂资源,避免磁盘泄露
# ----------------------------------------------------------------
if: always()
run: |
docker buildx rm "${{ steps.buildx.outputs.name }}" || true
- name: "❗ Handle build failure"
# ----------------------------------------------------------------
# 功能说明:统一处理失败,输出错误并发送通知
# ----------------------------------------------------------------
if: failure()
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
EMAIL_SMTP_ENDPOINT: ${{ secrets.EMAIL_SMTP_ENDPOINT }}
EMAIL_TO: ${{ secrets.EMAIL_TO }}
NOTIFY_FROM_EMAIL: ${{ secrets.NOTIFY_FROM_EMAIL || 'github-actions@users.noreply.github.com' }}
SMTP_USERNAME: ${{ secrets.EMAIL_SMTP_USERNAME }}
SMTP_PASSWORD: ${{ secrets.EMAIL_SMTP_PASSWORD }}
run: |
echo "::error::构建失败详情,检查上方日志。"
docker buildx inspect "${{ steps.buildx.outputs.name }}" >/tmp/buildx-inspect.log 2>&1 || true
docker info >/tmp/docker-info.log 2>&1 || true
echo "Buildx inspect (last 120 lines):"
tail -n 120 /tmp/buildx-inspect.log || true
echo "Docker info (last 120 lines):"
tail -n 120 /tmp/docker-info.log || true
if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then
payload=$(jq -n --arg text "GHCR 发布失败:${GITHUB_REPOSITORY} @ ${GITHUB_REF}. 请检查日志。" '{text:$text}')
curl -X POST -H "Content-Type: application/json" -d "${payload}" "${SLACK_WEBHOOK_URL}"
fi
if [ -n "${EMAIL_SMTP_ENDPOINT:-}" ] && [ -n "${EMAIL_TO:-}" ]; then
python - <<'PY'
import os, smtplib, ssl, sys
from email.message import EmailMessage
msg = EmailMessage()
msg["Subject"] = "GHCR 发布失败告警"
msg["From"] = os.environ.get("NOTIFY_FROM_EMAIL", "github-actions@users.noreply.github.com")
msg["To"] = os.environ["EMAIL_TO"]
msg.set_content(f"仓库 {os.environ['GITHUB_REPOSITORY']} 发布失败,参考运行 {os.environ['GITHUB_RUN_ID']}")
context = ssl.create_default_context()
try:
with smtplib.SMTP(os.environ["EMAIL_SMTP_ENDPOINT"]) as smtp:
smtp.starttls(context=context)
user = os.environ.get("SMTP_USERNAME")
pwd = os.environ.get("SMTP_PASSWORD")
if user and pwd:
smtp.login(user, pwd)
smtp.send_message(msg)
except Exception as exc:
print(f"::warning::邮件发送失败: {exc}", file=sys.stderr)
PY
fi