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 完整栈冒烟。
This commit is contained in:
Executable
+375
@@ -0,0 +1,375 @@
|
||||
#!/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'
|
||||
Reference in New Issue
Block a user