ci(deploy): 镜像发布 - 原生多架构构建并复用提交镜像与缓存
This commit is contained in:
30
.github/scripts/promote-image.sh
vendored
Normal file
30
.github/scripts/promote-image.sh
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${IMAGE:?}" "${GITHUB_SHA:?}" "${GITHUB_REF:?}" "${DIGEST_DIR:?}" "${EXPECTED_DIGESTS:?}"
|
||||
[[ "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid commit SHA" >&2; exit 1; }
|
||||
shopt -s nullglob
|
||||
files=("$DIGEST_DIR"/*)
|
||||
[[ ${#files[@]} -eq "$EXPECTED_DIGESTS" ]] || { echo "Incomplete image digest set" >&2; exit 1; }
|
||||
sources=()
|
||||
for file in "${files[@]}"; do
|
||||
digest=${file##*/}
|
||||
[[ "$digest" =~ ^[0-9a-f]{64}$ ]] || { echo "Invalid image digest" >&2; exit 1; }
|
||||
sources+=("$IMAGE@sha256:$digest")
|
||||
done
|
||||
|
||||
tags=(--tag "$IMAGE:sha-$GITHUB_SHA" --tag "$IMAGE:sha-${GITHUB_SHA:0:7}")
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
[[ "${VERSION_TAG:?}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || { echo "Invalid version image tag" >&2; exit 1; }
|
||||
tags+=(--tag "$IMAGE:$VERSION_TAG")
|
||||
elif [[ "$GITHUB_REF" == refs/heads/main ]]; then
|
||||
current_sha=$(gh api "repos/${GITHUB_REPOSITORY:?}/git/ref/heads/main" --jq '.object.sha')
|
||||
[[ "$current_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid main branch SHA" >&2; exit 1; }
|
||||
if [[ "$current_sha" == "$GITHUB_SHA" ]]; then
|
||||
tags+=(--tag "$IMAGE:latest")
|
||||
else
|
||||
echo 'A newer main commit exists; leaving latest unchanged.'
|
||||
fi
|
||||
fi
|
||||
|
||||
docker buildx imagetools create "${tags[@]}" "${sources[@]}"
|
||||
18
.github/scripts/resolve-image.sh
vendored
Normal file
18
.github/scripts/resolve-image.sh
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
image_ref=${1:?image reference is required}
|
||||
error_file=$(mktemp)
|
||||
trap 'rm -f "$error_file"' EXIT
|
||||
|
||||
if digest=$(docker buildx imagetools inspect "$image_ref" --format '{{.Manifest.Digest}}' 2>"$error_file"); then
|
||||
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "Invalid existing image digest" >&2; exit 1; }
|
||||
printf 'found=true\ndigest=%s\n' "$digest" >> "${GITHUB_OUTPUT:?}"
|
||||
elif grep -Fxq "ERROR: $image_ref: not found" "$error_file" \
|
||||
|| grep -Eqi '^ERROR:.*: (manifest unknown|name unknown)(:.*)?$' "$error_file"; then
|
||||
echo 'found=false' >> "${GITHUB_OUTPUT:?}"
|
||||
else
|
||||
# 鉴权、网络、限流错误不是缓存未命中,必须阻止发布。
|
||||
cat "$error_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
186
.github/workflows/publish-images.yml
vendored
186
.github/workflows/publish-images.yml
vendored
@@ -9,28 +9,32 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
# main 和 tag 的同一提交串行进入;后到的一轮复用已校验的 SHA 镜像。
|
||||
concurrency:
|
||||
group: publish-${{ github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish ${{ matrix.image }}
|
||||
checks:
|
||||
name: Quality checks
|
||||
uses: ./.github/workflows/quality.yml
|
||||
|
||||
prepare:
|
||||
name: Resolve build version
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- image: open-ai-canvas-web
|
||||
dockerfile: Dockerfile
|
||||
- image: open-ai-canvas-backend
|
||||
dockerfile: backend/Dockerfile
|
||||
|
||||
outputs:
|
||||
version: ${{ steps.info.outputs.version }}
|
||||
image_tag: ${{ steps.info.outputs.image_tag }}
|
||||
build_time: ${{ steps.info.outputs.build_time }}
|
||||
owner: ${{ steps.info.outputs.owner }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve and validate build version
|
||||
id: build-info
|
||||
- uses: actions/checkout@v4
|
||||
- id: info
|
||||
name: Validate release metadata
|
||||
env:
|
||||
REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(tr -d '\r\n' < VERSION)"
|
||||
@@ -40,68 +44,140 @@ jobs:
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "image_tag=${version#v}" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "is_semver=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_semver=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
# 相同提交的 main/tag/重跑使用相同时间,避免人为破坏缓存。
|
||||
echo "build_time=$(git show -s --format=%cI HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "owner=${REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
build:
|
||||
name: Build ${{ matrix.image }} (${{ matrix.arch }})
|
||||
needs: [checks, prepare]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Web 的构建阶段固定 BUILDPLATFORM,静态文件只编译一次。
|
||||
- image: open-ai-canvas-web
|
||||
dockerfile: Dockerfile
|
||||
arch: multi
|
||||
platforms: linux/amd64,linux/arm64
|
||||
runner: ubuntu-24.04
|
||||
- image: open-ai-canvas-backend
|
||||
dockerfile: backend/Dockerfile
|
||||
arch: amd64
|
||||
platforms: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- image: open-ai-canvas-backend
|
||||
dockerfile: backend/Dockerfile
|
||||
arch: arm64
|
||||
platforms: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ needs.prepare.outputs.owner }}/${{ matrix.image }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate image metadata
|
||||
id: metadata
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
|
||||
type=raw,value=${{ steps.build-info.outputs.image_tag }},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{version}},enable=${{ steps.build-info.outputs.is_semver == 'true' }}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build and push image
|
||||
- name: Reuse an existing commit image
|
||||
id: existing
|
||||
env:
|
||||
IMAGE_REF: ${{ env.IMAGE }}:sha-${{ github.sha }}-${{ matrix.arch }}
|
||||
run: bash .github/scripts/resolve-image.sh "$IMAGE_REF"
|
||||
- name: Build and push immutable commit image
|
||||
id: build
|
||||
if: steps.existing.outputs.found != 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: ${{ matrix.platforms }}
|
||||
push: true
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.build-info.outputs.version }}
|
||||
BUILD_VERSION=${{ needs.prepare.outputs.version }}
|
||||
BUILD_COMMIT=${{ github.sha }}
|
||||
BUILD_TIME=${{ steps.build-info.outputs.build_time }}
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
cache-from: type=gha,scope=${{ matrix.image }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.image }}
|
||||
BUILD_TIME=${{ needs.prepare.outputs.build_time }}
|
||||
tags: ${{ env.IMAGE }}:sha-${{ github.sha }}-${{ matrix.arch }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.version=${{ needs.prepare.outputs.version }}
|
||||
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.arch }}
|
||||
- name: Record image digest
|
||||
env:
|
||||
DIGEST: ${{ steps.existing.outputs.digest || steps.build.outputs.digest }}
|
||||
shell: bash
|
||||
run: |
|
||||
[[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "Invalid image digest" >&2; exit 1; }
|
||||
mkdir -p "$RUNNER_TEMP/digests"
|
||||
touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.image }}-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Publish ${{ matrix.image }} manifest
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
image: [open-ai-canvas-web, open-ai-canvas-backend]
|
||||
# 不同提交可并行编译,但同一镜像的可变标签更新必须串行。
|
||||
concurrency:
|
||||
group: image-tags-${{ matrix.image }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: digests-${{ matrix.image }}-*
|
||||
path: ${{ runner.temp }}/digests
|
||||
merge-multiple: true
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Promote verified digests
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
IMAGE: ghcr.io/${{ needs.prepare.outputs.owner }}/${{ matrix.image }}
|
||||
VERSION_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
DIGEST_DIR: ${{ runner.temp }}/digests
|
||||
EXPECTED_DIGESTS: ${{ matrix.image == 'open-ai-canvas-backend' && '2' || '1' }}
|
||||
run: bash .github/scripts/promote-image.sh
|
||||
|
||||
release:
|
||||
name: Publish release assets
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Build Host Updater
|
||||
working-directory: backend
|
||||
shell: bash
|
||||
@@ -112,7 +188,6 @@ jobs:
|
||||
done
|
||||
cd ../dist
|
||||
sha256sum open-ai-canvas-host-updater-linux-* > SHA256SUMS
|
||||
|
||||
- name: Prepare release notes
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -123,7 +198,6 @@ jobs:
|
||||
found { print }
|
||||
' CHANGELOG.md > dist/RELEASE_NOTES.md
|
||||
test -s dist/RELEASE_NOTES.md || printf '影策 %s\n' "$version" > dist/RELEASE_NOTES.md
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
36
.github/workflows/quality.yml
vendored
36
.github/workflows/quality.yml
vendored
@@ -2,9 +2,7 @@ name: Quality checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -21,11 +19,22 @@ jobs:
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
cache-dependency-path: backend/go.sum
|
||||
cache: true
|
||||
- name: Check formatting
|
||||
run: test -z "$(gofmt -l .)"
|
||||
- name: Run Go tests
|
||||
run: go test ./...
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -json ./... | tee "$RUNNER_TEMP/go-tests.jsonl"
|
||||
- name: Upload Go test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: go-test-timings
|
||||
path: ${{ runner.temp }}/go-tests.jsonl
|
||||
if-no-files-found: ignore
|
||||
|
||||
payment-plugins:
|
||||
name: Payment plugin artifacts
|
||||
@@ -36,15 +45,22 @@ jobs:
|
||||
env:
|
||||
PAYMENT_PLUGIN_SMOKE_TEST: "1"
|
||||
run: sh plugin-packages/verify-payment-packages.sh --all linux amd64
|
||||
# 发布时 backend/Dockerfile 会在两种架构的原生 Runner 上执行 smoke。
|
||||
# PR 仍独立验证双架构,不能因没有发布任务而遗漏 ARM64 验收。
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
if: github.event_name == 'pull_request'
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
if: github.event_name == 'pull_request'
|
||||
- name: Build and execute payment packages for every image platform
|
||||
run: >-
|
||||
docker buildx build
|
||||
--platform linux/amd64,linux/arm64
|
||||
--target payment-package-smoke
|
||||
--file backend/Dockerfile
|
||||
.
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
target: payment-package-smoke
|
||||
file: backend/Dockerfile
|
||||
cache-from: type=gha,scope=payment-smoke
|
||||
cache-to: type=gha,mode=max,scope=payment-smoke
|
||||
|
||||
web:
|
||||
name: Web checks
|
||||
|
||||
Reference in New Issue
Block a user