删除 Gitea Actions、Tag/Main 自动流水线和旧 Runner 配置,取消 Git 操作与发布授权的绑定。\n\n新增本地镜像发布、固定生产部署助手、digest manifest、迁移安全检查、simulation 冒烟及显式回滚流程。\n\n验证:pnpm lint、pnpm test、pnpm build、Go 全量测试、ShellCheck、Compose 配置、人工发布测试和 linux/amd64 完整栈冒烟。
376 lines
13 KiB
Bash
Executable File
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'
|