Files
easyai-ai-gateway/scripts/publish-release-images.sh
T
easyai dae5d16a58 refactor(release): 改为 Agent 双阶段人工发布
删除 Gitea Actions、Tag/Main 自动流水线和旧 Runner 配置,取消 Git 操作与发布授权的绑定。\n\n新增本地镜像发布、固定生产部署助手、digest manifest、迁移安全检查、simulation 冒烟及显式回滚流程。\n\n验证:pnpm lint、pnpm test、pnpm build、Go 全量测试、ShellCheck、Compose 配置、人工发布测试和 linux/amd64 完整栈冒烟。
2026-07-22 15:13:40 +08:00

376 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$root"
components=auto
production_host=${AI_GATEWAY_PRODUCTION_HOST:-root@110.42.51.33}
remote_helper=${AI_GATEWAY_REMOTE_RELEASE_HELPER:-/usr/local/sbin/easyai-ai-gateway-release}
registry=${AI_GATEWAY_IMAGE_REGISTRY:-registry.cn-shanghai.aliyuncs.com/easyaigc}
platform=linux/amd64
platform_probe_image=${AI_GATEWAY_PLATFORM_PROBE_IMAGE:-docker.m.daocloud.io/library/alpine:3.22}
status_file_override=${AI_GATEWAY_RELEASE_STATUS_FILE:-}
usage() {
cat <<'EOF'
Usage:
scripts/publish-release-images.sh [--components auto|api|web|all]
This command only builds, smoke-tests, and pushes immutable images. It never
changes production. The generated manifest must be deployed separately with
scripts/deploy-production-release.sh after explicit confirmation.
Environment:
AI_GATEWAY_PRODUCTION_HOST SSH target used for read-only release status
AI_GATEWAY_REMOTE_RELEASE_HELPER Fixed status/deploy helper on the server
AI_GATEWAY_IMAGE_REGISTRY Registry namespace
AI_GATEWAY_RELEASE_STATUS_FILE Local status manifest for offline/testing use
EOF
}
while [[ $# -gt 0 ]]; do
case $1 in
--components)
[[ $# -ge 2 ]] || { echo 'missing value for --components' >&2; exit 64; }
components=$2
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'unknown argument: %s\n' "$1" >&2
usage >&2
exit 64
;;
esac
done
case $components in
auto|api|web|all) ;;
*) echo '--components must be auto, api, web, or all' >&2; exit 64 ;;
esac
[[ $production_host =~ ^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$ ]] || {
echo 'AI_GATEWAY_PRODUCTION_HOST must use user@host syntax' >&2
exit 1
}
[[ $remote_helper =~ ^/[A-Za-z0-9._/-]+$ ]] || {
echo 'AI_GATEWAY_REMOTE_RELEASE_HELPER must be an absolute safe path' >&2
exit 1
}
[[ $registry =~ ^[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._-]+)+$ ]] || {
echo 'AI_GATEWAY_IMAGE_REGISTRY has an invalid format' >&2
exit 1
}
[[ $platform_probe_image =~ ^[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._-]+)*(:[A-Za-z0-9._-]+|@sha256:[0-9a-f]{64})$ ]] || {
echo 'AI_GATEWAY_PLATFORM_PROBE_IMAGE has an invalid format' >&2
exit 1
}
fail() {
echo "release_publish=FAIL $*" >&2
exit 1
}
for command in git node docker curl; do
command -v "$command" >/dev/null 2>&1 || fail "$command is required"
done
docker info >/dev/null 2>&1 || fail 'Docker Engine is not reachable'
docker buildx version >/dev/null 2>&1 || fail 'Docker Buildx is required'
docker compose version >/dev/null 2>&1 || fail 'Docker Compose v2 is required'
git fetch --quiet origin main
preflight_file=$(mktemp)
node scripts/release-preflight.mjs >"$preflight_file"
source_sha=$(node -e \
'const value=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(value.sourceSha)' \
"$preflight_file")
status_file=$(mktemp)
selection_file=$(mktemp)
smoke_project=easyai-release-smoke-${source_sha:0:12}
smoke_started=0
cleanup() {
local status=$?
trap - EXIT HUP INT TERM
if [[ $smoke_started -eq 1 ]]; then
COMPOSE_PROJECT_NAME=$smoke_project \
docker compose -p "$smoke_project" -f docker-compose.yml down -v --remove-orphans \
>/dev/null 2>&1 || true
fi
rm -f "$status_file" "$selection_file" "$preflight_file"
exit "$status"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
has_status=0
if [[ -n $status_file_override ]]; then
[[ -f $status_file_override ]] || fail 'AI_GATEWAY_RELEASE_STATUS_FILE does not exist'
cp "$status_file_override" "$status_file"
has_status=1
elif ssh -o BatchMode=yes -o ConnectTimeout=8 "$production_host" \
"$remote_helper status" >"$status_file" 2>/dev/null; then
has_status=1
fi
base_sha=
current_api_image=
current_web_image=
if [[ $has_status -eq 1 ]]; then
node scripts/release-manifest.mjs validate "$status_file" >/dev/null
base_sha=$(node scripts/release-manifest.mjs get "$status_file" sourceSha)
current_api_image=$(node scripts/release-manifest.mjs get "$status_file" images.api)
current_web_image=$(node scripts/release-manifest.mjs get "$status_file" images.web)
fi
if [[ $components != all && $has_status -ne 1 ]]; then
fail 'auto/api/web publishing requires a readable production release; use --components all for the first release'
fi
required_components=all
migrations_changed=true
base_verifiable=0
if [[ $has_status -eq 1 ]]; then
if ! git cat-file -e "$base_sha^{commit}" 2>/dev/null; then
git fetch --quiet origin "$base_sha" >/dev/null 2>&1 || true
fi
if git cat-file -e "$base_sha^{commit}" 2>/dev/null; then
git merge-base --is-ancestor "$base_sha" "$source_sha" || \
fail 'the production release is not an ancestor of the source commit'
base_verifiable=1
node scripts/release-components.mjs "$base_sha" "$source_sha" >"$selection_file"
required_components=$(node -e \
'const value=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(value.components)' \
"$selection_file")
migrations_changed=$(node -e \
'const value=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(String(value.migrationsChanged))' \
"$selection_file")
elif [[ $components != all ]]; then
fail 'the production source SHA cannot be verified; rerun with --components all'
else
echo '[release] production source SHA is unavailable; conservatively building all components and running migrations' >&2
fi
fi
if [[ $components == auto ]]; then
components=$required_components
fi
if [[ $components == none ]]; then
echo 'release_publish=SKIP reason=no_runtime_changes'
exit 0
fi
if [[ $required_components == all && $components != all ]]; then
fail "the source change requires both images; requested $components"
fi
if [[ $required_components == api && $components == web ]]; then
fail 'the source change requires the API image'
fi
if [[ $required_components == web && $components == api ]]; then
fail 'the source change requires the Web image'
fi
if [[ $migrations_changed == true && $components != api && $components != all ]]; then
fail 'migration changes require the API image'
fi
api_repository=$registry/ai-gateway
web_repository=$registry/ai-gateway-web
api_tag=$api_repository:$source_sha
web_tag=$web_repository:$source_sha
remote_tag_must_not_exist() {
local image=$1
local output status
set +e
output=$(docker buildx imagetools inspect "$image" 2>&1)
status=$?
set -e
if [[ $status -eq 0 ]]; then
fail "immutable image tag already exists: $image"
fi
if ! grep -Eqi 'not found|manifest unknown|does not exist' <<<"$output"; then
fail "cannot verify registry login and repository access for $image"
fi
}
case $components in
api) remote_tag_must_not_exist "$api_tag" ;;
web) remote_tag_must_not_exist "$web_tag" ;;
all)
remote_tag_must_not_exist "$api_tag"
remote_tag_must_not_exist "$web_tag"
;;
esac
docker run --rm --platform "$platform" "$platform_probe_image" true >/dev/null 2>&1 || \
fail "Docker cannot execute $platform containers"
if [[ $base_verifiable -eq 1 ]]; then
node scripts/ci-validate-migrations.mjs "$base_sha"
fi
echo '[release] running fast Go tests without database integration variables'
(
cd apps/api
env -u AI_GATEWAY_TEST_DATABASE_URL go test ./... -count=1
)
api_image=$current_api_image
web_image=$current_web_image
build_image() {
local target=$1
local image=$2
local -a build_args=()
case $target in
api)
build_args=(
--build-arg "GOPROXY=${AI_GATEWAY_GO_PROXY:-https://goproxy.cn,direct}"
--build-arg "GO_BUILD_IMAGE=${AI_GATEWAY_GO_BUILD_IMAGE:-golang:1.26.3-alpine}"
--build-arg "API_RUNTIME_IMAGE=${AI_GATEWAY_API_RUNTIME_IMAGE:-alpine:3.22}"
)
;;
web)
build_args=(
--build-arg "VITE_GATEWAY_API_BASE_URL=${AI_GATEWAY_WEB_API_BASE_URL:-/gateway-api}"
--build-arg "NODE_BUILD_IMAGE=${AI_GATEWAY_NODE_BUILD_IMAGE:-node:22-alpine}"
--build-arg "WEB_RUNTIME_IMAGE=${AI_GATEWAY_WEB_RUNTIME_IMAGE:-nginx:1.27-alpine}"
--build-arg "NPM_CONFIG_REGISTRY=${AI_GATEWAY_NPM_REGISTRY:-https://registry.npmjs.org}"
--build-arg "VITE_BASE_PATH=${AI_GATEWAY_WEB_BASE_PATH:-/}"
)
;;
esac
echo "[release] building $target for $platform: $image"
docker buildx build \
--platform "$platform" \
--file Dockerfile \
--target "$target" \
--label "org.opencontainers.image.revision=$source_sha" \
--label 'org.opencontainers.image.source=https://git.51easyai.com/BCAI/easyai-ai-gateway' \
--tag "$image" \
--load \
"${build_args[@]}" \
.
[[ $(docker image inspect "$image" --format '{{.Os}}/{{.Architecture}}') == "$platform" ]] || \
fail "$image is not $platform"
[[ $(docker image inspect "$image" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}') == "$source_sha" ]] || \
fail "$image is missing the source revision label"
}
case $components in
api)
build_image api "$api_tag"
api_image=$api_tag
;;
web)
build_image web "$web_tag"
web_image=$web_tag
;;
all)
build_image api "$api_tag"
build_image web "$web_tag"
api_image=$api_tag
web_image=$web_tag
;;
esac
for image in "$api_image" "$web_image"; do
docker image inspect "$image" >/dev/null 2>&1 || docker pull --platform "$platform" "$image"
done
build_completed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
export COMPOSE_PROJECT_NAME=$smoke_project
export AI_GATEWAY_PLATFORM=$platform
export AI_GATEWAY_API_IMAGE=$api_image
export AI_GATEWAY_WEB_IMAGE=$web_image
export AI_GATEWAY_API_PORT=0
export AI_GATEWAY_WEB_PORT=0
export AI_GATEWAY_DB_PORT=0
export AI_GATEWAY_COMPOSE_APP_ENV=test
export AI_GATEWAY_COMPOSE_IDENTITY_MODE=hybrid
export AI_GATEWAY_COMPOSE_DATABASE_NAME=easyai_ai_gateway
export AI_GATEWAY_COMPOSE_PG_USER=easyai
export AI_GATEWAY_COMPOSE_PG_PASSWORD=release-smoke-postgres
export AI_GATEWAY_COMPOSE_DATABASE_URL='postgresql://easyai:release-smoke-postgres@postgres:5432/easyai_ai_gateway?sslmode=disable'
export CONFIG_JWT_SECRET=release-smoke-$source_sha
smoke_started=1
docker compose -p "$smoke_project" -f docker-compose.yml up -d --pull never postgres
for _ in $(seq 1 60); do
postgres_id=$(docker compose -p "$smoke_project" -f docker-compose.yml ps -q postgres)
postgres_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$postgres_id" 2>/dev/null || true)
[[ $postgres_health == healthy ]] && break
sleep 1
done
[[ ${postgres_health:-} == healthy ]] || fail 'ephemeral PostgreSQL did not become healthy'
docker compose -p "$smoke_project" -f docker-compose.yml run --rm --no-deps migrator
docker compose -p "$smoke_project" -f docker-compose.yml up -d --pull never --no-build api web
api_port=$(docker compose -p "$smoke_project" -f docker-compose.yml port api 8088 | tail -n 1)
web_port=$(docker compose -p "$smoke_project" -f docker-compose.yml port web 80 | tail -n 1)
api_port=${api_port##*:}
web_port=${web_port##*:}
[[ $api_port =~ ^[0-9]+$ && $web_port =~ ^[0-9]+$ ]] || fail 'could not resolve smoke ports'
RELEASE_SMOKE_BASE_URL=http://127.0.0.1:$api_port \
RELEASE_SMOKE_WEB_URL=http://127.0.0.1:$web_port \
RELEASE_SMOKE_COMPOSE_PROJECT=$smoke_project \
RELEASE_SMOKE_COMPOSE_FILE=$root/docker-compose.yml \
RELEASE_SMOKE_NONCE=${source_sha:0:12} \
RELEASE_SMOKE_DATABASE_USER=$AI_GATEWAY_COMPOSE_PG_USER \
RELEASE_SMOKE_DATABASE_NAME=$AI_GATEWAY_COMPOSE_DATABASE_NAME \
node scripts/api-release-smoke.mjs
smoke_completed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
push_and_resolve() {
local image=$1
local repository=$2
local resolved
docker push "$image" >&2
resolved=$(docker image inspect "$image" --format '{{range .RepoDigests}}{{println .}}{{end}}' | \
awk -v prefix="$repository@sha256:" 'index($0, prefix) == 1 { print; exit }')
[[ $resolved =~ ^[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._-]+)+@sha256:[0-9a-f]{64}$ ]] || \
fail "could not resolve registry digest for $image"
printf '%s\n' "$resolved"
}
case $components in
api)
api_image=$(push_and_resolve "$api_tag" "$api_repository")
component_list=api
;;
web)
web_image=$(push_and_resolve "$web_tag" "$web_repository")
component_list=web
;;
all)
api_image=$(push_and_resolve "$api_tag" "$api_repository")
web_image=$(push_and_resolve "$web_tag" "$web_repository")
component_list=api,web
;;
esac
mkdir -p dist/releases
manifest=$root/dist/releases/$source_sha.json
[[ ! -e $manifest ]] || fail "release manifest already exists: $manifest"
RELEASE_SOURCE_SHA=$source_sha \
RELEASE_BASE_SHA=$base_sha \
RELEASE_COMPONENTS=$component_list \
RELEASE_MIGRATIONS_CHANGED=$migrations_changed \
RELEASE_API_IMAGE=$api_image \
RELEASE_WEB_IMAGE=$web_image \
RELEASE_BUILD_COMPLETED_AT=$build_completed_at \
RELEASE_SMOKE_COMPLETED_AT=$smoke_completed_at \
node scripts/release-manifest.mjs create "$manifest"
echo "release_publish=PASS release=$source_sha components=$component_list"
echo "release_manifest=$manifest"
echo "api_image=$api_image"
echo "web_image=$web_image"
echo 'production_changed=false'