Files
gongxue-base/scripts/deploy/bin/deploy.sh
2026-07-12 19:26:57 +08:00

443 lines
14 KiB
Bash

#!/usr/bin/env bash
set -Eeuo pipefail
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
export GIT_TERMINAL_PROMPT=0
CONFIG_FILE="${CONFIG_FILE:-/etc/tiku-saas/deploy.env}"
if [[ ! -r "$CONFIG_FILE" ]]; then
echo "Missing deploy config: $CONFIG_FILE" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$CONFIG_FILE"
: "${GIT_REPO:?GIT_REPO is required}"
: "${GIT_BRANCH:=main}"
: "${APP_ROOT:=/opt/tiku-saas}"
: "${SOURCE_REPO_DIR:=$APP_ROOT/source}"
: "${REPO_DIR:=$APP_ROOT/repo}"
: "${RELEASES_DIR:=$APP_ROOT/releases}"
: "${CURRENT_LINK:=$APP_ROOT/current}"
: "${WWW_ROOT:=/srv/tiku-saas/www}"
: "${WWW_RELEASES_DIR:=${WWW_ROOT%/}-releases}"
: "${RUNTIME_CONFIG_DIR:=/etc/tiku-saas/runtime-config}"
: "${KEEP_RELEASES:=5}"
: "${RUN_SECURITY_CHECKS:=true}"
: "${RUN_RUNTIME_AUDIT:=true}"
: "${RUN_TARO_SUPPLY_CHAIN_AUDIT:=true}"
: "${RUN_LAUNCH_GATE:=true}"
: "${RUN_DB_MIGRATIONS:=false}"
: "${RUN_DB_READINESS:=true}"
: "${DATABASE_MIGRATION_URL:=}"
: "${DB_MIGRATION_COMMAND:=supabase db push --db-url \"\$DATABASE_MIGRATION_URL\"}"
: "${API_ENV_FILE:=/etc/tiku-saas/api.env}"
: "${RESTART_SERVICES:=true}"
: "${SYSTEMD_UNITS:=tiku-api.service tiku-workers.target}"
: "${HEALTHCHECK_URL:=http://127.0.0.1:8787/health}"
: "${HEALTHCHECK_TIMEOUT_SECONDS:=60}"
: "${HEALTHCHECK_INTERVAL_SECONDS:=2}"
: "${NPM_REGISTRY:=https://registry.npmjs.org/}"
: "${NPM_AUDIT_REGISTRY:=https://registry.npmjs.org/}"
: "${NPM_FETCH_RETRIES:=5}"
: "${NPM_FETCH_RETRY_MINTIMEOUT:=20000}"
: "${NPM_FETCH_RETRY_MAXTIMEOUT:=120000}"
: "${NPM_FETCH_TIMEOUT:=300000}"
export DATABASE_MIGRATION_URL
LOCK_DIR="${LOCK_DIR:-$APP_ROOT/.deploy.lock}"
LOCK_ACQUIRED=false
ASKPASS_FILE=""
CANDIDATE_RELEASE=""
PREVIOUS_APP_RELEASE=""
PREVIOUS_WWW_RELEASE=""
BOOTSTRAP_SERVICE_BACKUP=""
ROLLBACK_ARMED=false
APP_SWITCHED=false
SERVICE_SYNC_STARTED=false
WWW_SWITCH_STARTED=false
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
}
die() {
printf '[deploy][error] %s\n' "$*" >&2
exit 1
}
truthy() {
case "${1:-}" in
1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;;
*) return 1 ;;
esac
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
run_shell() {
log "+ $*"
bash -lc "$*"
}
load_runtime_env() {
[[ -r "$API_ENV_FILE" ]] || die "Missing API runtime config: $API_ENV_FILE"
set -a
# shellcheck disable=SC1090
source "$API_ENV_FILE"
set +a
}
atomic_symlink() {
local target="$1"
local link="$2"
local next_link="${link}.next.$$"
rm -f "$next_link"
ln -s "$target" "$next_link"
if [[ -L "$link" || ! -e "$link" ]]; then
mv -Tf "$next_link" "$link"
return 0
fi
rm -f "$next_link"
return 1
}
restart_services() {
truthy "$RESTART_SERVICES" || die "Production deployment requires RESTART_SERVICES=true"
[[ -n "$SYSTEMD_UNITS" ]] || die "SYSTEMD_UNITS is required"
local unit
for unit in $SYSTEMD_UNITS; do
systemctl restart "$unit" || return 1
done
for unit in $SYSTEMD_UNITS; do
systemctl is-active --quiet "$unit" || return 1
done
}
healthcheck() {
[[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL"
local deadline=$((SECONDS + HEALTHCHECK_TIMEOUT_SECONDS))
log "Waiting for API healthcheck: $HEALTHCHECK_URL"
while (( SECONDS < deadline )); do
if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
log "API healthcheck passed."
return 0
fi
sleep "$HEALTHCHECK_INTERVAL_SECONDS"
done
return 1
}
sync_service_repo() {
local release="$1"
[[ -d "$release" ]] || return 1
if [[ -L "$REPO_DIR" ]]; then
[[ "$(readlink -f "$REPO_DIR")" == "$(readlink -f "$release")" ]] \
|| die "REPO_DIR symlink must resolve to the selected current release"
return 0
fi
mkdir -p "$REPO_DIR"
rsync -a --delete --exclude .git "$release/" "$REPO_DIR/"
}
rollback() {
local failed=false
ROLLBACK_ARMED=false
if [[ "$WWW_SWITCH_STARTED" == "true" && -n "$PREVIOUS_WWW_RELEASE" ]]; then
log "Rolling Web root back to $PREVIOUS_WWW_RELEASE"
atomic_symlink "$PREVIOUS_WWW_RELEASE" "$WWW_ROOT" || failed=true
elif [[ "$WWW_SWITCH_STARTED" == "true" ]]; then
rm -f "$WWW_ROOT" || failed=true
fi
if [[ "$APP_SWITCHED" == "true" && -n "$PREVIOUS_APP_RELEASE" ]]; then
log "Rolling application current link back to $PREVIOUS_APP_RELEASE"
atomic_symlink "$PREVIOUS_APP_RELEASE" "$CURRENT_LINK" || failed=true
elif [[ "$APP_SWITCHED" == "true" ]]; then
rm -f "$CURRENT_LINK" || failed=true
fi
if [[ "$SERVICE_SYNC_STARTED" == "true" && -n "$PREVIOUS_APP_RELEASE" ]]; then
sync_service_repo "$PREVIOUS_APP_RELEASE" || failed=true
elif [[ "$SERVICE_SYNC_STARTED" == "true" && -n "$BOOTSTRAP_SERVICE_BACKUP" ]]; then
log "Restoring pre-release service runtime"
sync_service_repo "$BOOTSTRAP_SERVICE_BACKUP" || failed=true
fi
if [[ "$SERVICE_SYNC_STARTED" == "true" ]]; then
restart_services || failed=true
healthcheck || failed=true
fi
[[ "$failed" == "false" ]]
}
cleanup() {
local status=$?
if [[ "$status" -ne 0 && "$ROLLBACK_ARMED" == "true" ]]; then
rollback || true
fi
if [[ -n "$ASKPASS_FILE" && -f "$ASKPASS_FILE" ]]; then
rm -f "$ASKPASS_FILE"
fi
if [[ "$LOCK_ACQUIRED" == "true" && -d "$LOCK_DIR" ]]; then
rmdir "$LOCK_DIR" 2>/dev/null || true
fi
}
trap cleanup EXIT
prepare_git_auth() {
if [[ -n "${GIT_SSH_COMMAND:-}" ]]; then
export GIT_SSH_COMMAND
return
fi
if [[ -z "${GIT_USERNAME:-}" || -z "${GITEA_TOKEN:-}" ]]; then
return
fi
export GIT_USERNAME GITEA_TOKEN
ASKPASS_FILE="$(mktemp)"
chmod 700 "$ASKPASS_FILE"
cat > "$ASKPASS_FILE" <<'ASKPASS'
#!/usr/bin/env bash
case "$1" in
*Username*) printf '%s\n' "$GIT_USERNAME" ;;
*Password*) printf '%s\n' "$GITEA_TOKEN" ;;
*) printf '\n' ;;
esac
ASKPASS
export GIT_ASKPASS="$ASKPASS_FILE"
export GIT_TERMINAL_PROMPT=0
}
install_runtime_config() {
local source_file="$1"
local target_dir="$2"
[[ -r "$source_file" ]] || die "Missing runtime config: $source_file"
install -m 0644 "$source_file" "$target_dir/runtime-config.json"
}
stage_candidate() {
local release_name="$1"
local staging="$RELEASES_DIR/.tmp-$release_name"
rm -rf "$staging"
mkdir -p "$staging"
rsync -a --delete --exclude .git --exclude node_modules "$SOURCE_REPO_DIR/" "$staging/"
if ! cp -al "$SOURCE_REPO_DIR/node_modules" "$staging/node_modules"; then
rm -rf "$staging/node_modules"
rsync -a "$SOURCE_REPO_DIR/node_modules/" "$staging/node_modules/"
fi
CANDIDATE_RELEASE="$RELEASES_DIR/$release_name"
rm -rf "$CANDIDATE_RELEASE"
mv "$staging" "$CANDIDATE_RELEASE"
}
build_and_validate_candidate() {
cd "$CANDIDATE_RELEASE"
if truthy "$RUN_SECURITY_CHECKS"; then
log "Running repository security scan against the candidate..."
GIT_DIR="$SOURCE_REPO_DIR/.git" GIT_WORK_TREE="$CANDIDATE_RELEASE" npm run security:repo
fi
log "Building API, worker and H5 portals..."
npm run build:api
npm run build:worker
npm run check:taro
if truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT"; then
npm run audit:taro:supply-chain
fi
npm run build:taro:h5:student
npm run build:taro:h5:tenant
npm run build:taro:h5:platform
log "Installing candidate H5 runtime configs..."
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-student.runtime-config.json" "apps/taro/dist/h5-student"
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-tenant-admin.runtime-config.json" "apps/taro/dist/h5-tenant-admin"
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-platform-admin.runtime-config.json" "apps/taro/dist/h5-platform-admin"
log "Validating candidate H5 artifacts before touching $WWW_ROOT..."
node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config
npm run manifest:taro:h5 -- --require-dist --require-runtime-config
npm run smoke:taro:h5
TARO_H5_INTERACTION_OUTPUT_DIR="${TARO_H5_INTERACTION_OUTPUT_DIR:-/tmp/tiku-h5-smoke}" npm run smoke:taro:h5:interaction
if truthy "$RUN_RUNTIME_AUDIT"; then
NPM_AUDIT_REGISTRY="$NPM_AUDIT_REGISTRY" npm run audit:runtime
fi
load_runtime_env
log "Running production environment readiness before the database migration step..."
npm run readiness:production
if truthy "$RUN_DB_MIGRATIONS"; then
log "Applying Supabase database migrations..."
run_shell "$DB_MIGRATION_COMMAND"
fi
if truthy "$RUN_DB_READINESS"; then
log "Running production database readiness after the optional migration step..."
npm run readiness:production:db
fi
if truthy "$RUN_LAUNCH_GATE"; then
: "${PRODUCTION_LAUNCH_EVIDENCE:?PRODUCTION_LAUNCH_EVIDENCE is required when RUN_LAUNCH_GATE=true}"
log "Running production launch gate against the candidate..."
DEPLOY_COMMIT_SHA="$(git -C "$SOURCE_REPO_DIR" rev-parse HEAD)" \
DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE" \
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE"
fi
}
verify_live_h5_release() {
truthy "$RUN_LAUNCH_GATE" || return 0
: "${PRODUCTION_LAUNCH_EVIDENCE:?PRODUCTION_LAUNCH_EVIDENCE is required when RUN_LAUNCH_GATE=true}"
log "Verifying the activated H5 release against production URLs..."
(
cd "$CANDIDATE_RELEASE"
DEPLOY_COMMIT_SHA="$(git -C "$SOURCE_REPO_DIR" rev-parse HEAD)" \
DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE" \
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE" --verify-live-h5
)
}
stage_www_candidate() {
local release_name="$1"
local staging="$WWW_RELEASES_DIR/.tmp-$release_name"
local final="$WWW_RELEASES_DIR/$release_name"
rm -rf "$staging"
mkdir -p "$staging/student" "$staging/tenant-admin" "$staging/platform-admin"
rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-student/" "$staging/student/"
rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-tenant-admin/" "$staging/tenant-admin/"
rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-platform-admin/" "$staging/platform-admin/"
rm -rf "$final"
mv "$staging" "$final"
printf '%s\n' "$final"
}
switch_www_release() {
local candidate_www="$1"
WWW_SWITCH_STARTED=true
if [[ -L "$WWW_ROOT" ]]; then
PREVIOUS_WWW_RELEASE="$(readlink -f "$WWW_ROOT")"
elif [[ -d "$WWW_ROOT" ]]; then
PREVIOUS_WWW_RELEASE="$WWW_RELEASES_DIR/bootstrap-www-$(date +%Y%m%d%H%M%S)"
mv "$WWW_ROOT" "$PREVIOUS_WWW_RELEASE"
elif [[ -e "$WWW_ROOT" ]]; then
die "WWW_ROOT exists but is not a directory or symlink: $WWW_ROOT"
fi
atomic_symlink "$candidate_www" "$WWW_ROOT"
}
prune_releases() {
local directory="$1"
[[ "$KEEP_RELEASES" =~ ^[0-9]+$ ]] || return 0
(( KEEP_RELEASES > 0 )) || return 0
find "$directory" -mindepth 1 -maxdepth 1 -type d ! -name '.*' ! -name 'bootstrap-*' -print \
| sort -r \
| tail -n +"$((KEEP_RELEASES + 1))" \
| while IFS= read -r old_release; do
[[ "$old_release" == "$CANDIDATE_RELEASE" || "$old_release" == "$PREVIOUS_APP_RELEASE" || "$old_release" == "$PREVIOUS_WWW_RELEASE" ]] && continue
rm -rf "$old_release"
done
}
main() {
require_command git
require_command npm
require_command rsync
require_command curl
[[ "$WWW_RELEASES_DIR" != "$WWW_ROOT" ]] || die "WWW_RELEASES_DIR must be outside WWW_ROOT"
case "${WWW_RELEASES_DIR%/}/" in
"${WWW_ROOT%/}/"*) die "WWW_RELEASES_DIR must not be nested under WWW_ROOT" ;;
esac
[[ "$SOURCE_REPO_DIR" != "$REPO_DIR" ]] || die "SOURCE_REPO_DIR must be separate from the systemd REPO_DIR"
truthy "$RESTART_SERVICES" || die "Production deployment requires RESTART_SERVICES=true"
[[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL"
truthy "$RUN_LAUNCH_GATE" || die "Production deployment requires RUN_LAUNCH_GATE=true"
truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT" \
|| die "Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true"
truthy "$RUN_DB_READINESS" \
|| die "Production deployment requires RUN_DB_READINESS=true before launch gate"
if truthy "$RUN_DB_MIGRATIONS"; then
[[ -n "$DATABASE_MIGRATION_URL" ]] \
|| die "Production database migrations require a separate DATABASE_MIGRATION_URL"
fi
mkdir -p "$APP_ROOT" "$RELEASES_DIR" "$WWW_RELEASES_DIR"
mkdir "$LOCK_DIR" 2>/dev/null || die "Another deployment is already running: $LOCK_DIR"
LOCK_ACQUIRED=true
prepare_git_auth
if [[ ! -d "$SOURCE_REPO_DIR/.git" ]]; then
log "Cloning source repository..."
git clone --branch "$GIT_BRANCH" "$GIT_REPO" "$SOURCE_REPO_DIR"
fi
log "Fetching $GIT_BRANCH..."
if [[ -n "$(git -C "$SOURCE_REPO_DIR" status --porcelain --untracked-files=normal)" ]]; then
die "Source repository has local changes; refusing to deploy."
fi
git -C "$SOURCE_REPO_DIR" fetch origin "$GIT_BRANCH" --prune
git -C "$SOURCE_REPO_DIR" checkout "$GIT_BRANCH"
git -C "$SOURCE_REPO_DIR" merge --ff-only "origin/$GIT_BRANCH"
local commit release_name
commit="$(git -C "$SOURCE_REPO_DIR" rev-parse --short=12 HEAD)"
release_name="$(date +%Y%m%d%H%M%S)-$commit"
log "Preparing candidate commit $commit"
log "Installing locked dependencies in the source checkout..."
npm --prefix "$SOURCE_REPO_DIR" ci \
--workspaces \
--include-workspace-root \
--include=dev \
--registry="$NPM_REGISTRY" \
--fetch-retries="$NPM_FETCH_RETRIES" \
--fetch-retry-mintimeout="$NPM_FETCH_RETRY_MINTIMEOUT" \
--fetch-retry-maxtimeout="$NPM_FETCH_RETRY_MAXTIMEOUT" \
--fetch-timeout="$NPM_FETCH_TIMEOUT"
stage_candidate "$release_name"
build_and_validate_candidate
local candidate_www
candidate_www="$(stage_www_candidate "$release_name")"
if [[ -L "$CURRENT_LINK" ]]; then
PREVIOUS_APP_RELEASE="$(readlink -f "$CURRENT_LINK")"
elif [[ -d "$REPO_DIR" ]]; then
BOOTSTRAP_SERVICE_BACKUP="$RELEASES_DIR/bootstrap-service-$(date +%Y%m%d%H%M%S)"
mkdir -p "$BOOTSTRAP_SERVICE_BACKUP"
rsync -a --delete --exclude .git "$REPO_DIR/" "$BOOTSTRAP_SERVICE_BACKUP/"
fi
ROLLBACK_ARMED=true
atomic_symlink "$CANDIDATE_RELEASE" "$CURRENT_LINK" || die "Failed to switch application current release"
APP_SWITCHED=true
SERVICE_SYNC_STARTED=true
sync_service_repo "$CANDIDATE_RELEASE"
restart_services
healthcheck
switch_www_release "$candidate_www" || die "Failed to switch Web release"
verify_live_h5_release
ROLLBACK_ARMED=false
prune_releases "$RELEASES_DIR"
prune_releases "$WWW_RELEASES_DIR"
log "Deployment finished: $commit"
}
main "$@"