#!/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'