feat: establish production SaaS foundation

This commit is contained in:
Codex
2026-07-12 19:26:57 +08:00
parent 1c2ce38cea
commit 39f7332f33
219 changed files with 20647 additions and 2628 deletions

686
deploy.sh Executable file
View File

@@ -0,0 +1,686 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Safe release-style deploy script for tiku-supabase.
#
# Server layout:
# DEPLOY_ROOT/
# current -> releases/<timestamp>-<commit>
# releases/
# shared/
# deploy.env # deploy settings, not committed
# .env # API/worker production env, not committed
# h5-student.runtime-config.json
# h5-tenant-admin.runtime-config.json
# h5-platform-admin.runtime-config.json
#
# Recommended first server run:
# mkdir -p /opt/tiku-saas/shared
# cp deploy.env.example /opt/tiku-saas/shared/deploy.env
# vim /opt/tiku-saas/shared/deploy.env
# vim /opt/tiku-saas/shared/.env
# bash deploy.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
log() {
printf '[deploy] %s\n' "$*"
}
warn() {
printf '[deploy][warn] %s\n' "$*" >&2
}
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
}
run() {
log "+ $*"
"$@"
}
run_shell() {
log "+ $*"
bash -lc "$*"
}
source_if_exists() {
local file="$1"
if [[ -f "$file" ]]; then
# shellcheck source=/dev/null
source "$file"
fi
}
export_dotenv_if_exists() {
local file="$1"
local line key value
[[ -f "$file" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}"
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
line="${line#export }"
[[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || continue
key="${line%%=*}"
value="${line#*=}"
if [[ "$value" == \"*\" && "$value" == *\" ]]; then
value="${value:1:${#value}-2}"
elif [[ "$value" == \'*\' && "$value" == *\' ]]; then
value="${value:1:${#value}-2}"
fi
if [[ -z "${!key+x}" ]]; then
export "$key=$value"
fi
done < "$file"
}
if [[ -n "${DEPLOY_CONFIG:-}" ]]; then
source_if_exists "$DEPLOY_CONFIG"
else
source_if_exists "$SCRIPT_DIR/deploy.env"
source_if_exists "$SCRIPT_DIR/.deploy.env"
fi
: "${APP_NAME:=tiku-supabase}"
: "${REPO_URL:=https://git.gongxue100.com/chenhaogxjy/tiku-supabase.git}"
: "${BRANCH:=main}"
: "${DEPLOY_ROOT:=/opt/tiku-saas}"
source_if_exists "$DEPLOY_ROOT/shared/deploy.env"
: "${RELEASES_DIR:=$DEPLOY_ROOT/releases}"
: "${SHARED_DIR:=$DEPLOY_ROOT/shared}"
: "${CURRENT_LINK:=$DEPLOY_ROOT/current}"
: "${KEEP_RELEASES:=5}"
: "${GIT_DEPTH:=1}"
: "${NODE_ENV:=production}"
: "${NPM_INSTALL_COMMAND:=npm ci --workspaces --include-workspace-root --include=dev}"
: "${NPM_AUDIT_REGISTRY:=https://registry.npmjs.org/}"
: "${RUN_CHECKS:=true}"
: "${CHECK_COMMANDS:=npm run check:api
npm run check:worker
npm run check:taro}"
: "${RUN_API_BUILD:=true}"
: "${RUN_WORKER_BUILD:=true}"
: "${RUN_TARO_H5_BUILD:=true}"
: "${RUN_SECURITY_REPO_SCAN:=true}"
: "${RUN_RUNTIME_AUDIT:=true}"
: "${RUN_TARO_SUPPLY_CHAIN_AUDIT:=true}"
: "${RUN_PRODUCTION_READINESS:=auto}"
: "${RUN_DB_READINESS:=true}"
: "${RUN_DB_MIGRATIONS:=false}"
: "${DATABASE_MIGRATION_URL:=}"
: "${DB_MIGRATION_COMMAND:=supabase db push --db-url \"\$DATABASE_MIGRATION_URL\"}"
: "${STRICT_H5_RUNTIME_CONFIG:=true}"
: "${RUN_H5_SMOKE:=true}"
: "${RUN_LAUNCH_GATE:=true}"
: "${PRODUCTION_LAUNCH_EVIDENCE:=$SHARED_DIR/production-launch-evidence.json}"
: "${H5_STUDENT_RUNTIME_CONFIG:=$SHARED_DIR/h5-student.runtime-config.json}"
: "${H5_TENANT_RUNTIME_CONFIG:=$SHARED_DIR/h5-tenant-admin.runtime-config.json}"
: "${H5_PLATFORM_RUNTIME_CONFIG:=$SHARED_DIR/h5-platform-admin.runtime-config.json}"
: "${WWW_ROOT:=/srv/tiku-saas/www}"
: "${WWW_RELEASES_DIR:=${WWW_ROOT%/}-releases}"
: "${WWW_CURRENT_LINK:=$WWW_ROOT}"
: "${SERVICE_REPO_DIR:=/opt/tiku-saas/repo}"
: "${SYNC_SERVICE_REPO:=true}"
: "${SERVICE_MODE:=systemd}"
: "${SYSTEMD_UNITS:=tiku-api.service tiku-workers.target}"
: "${PM2_ECOSYSTEM:=}"
: "${PM2_PROCESS_NAMES:=}"
: "${COMPOSE_FILE:=}"
: "${RESTART_COMMAND:=}"
: "${HEALTHCHECK_URL:=http://127.0.0.1:8787/health}"
: "${HEALTHCHECK_TIMEOUT_SECONDS:=60}"
: "${HEALTHCHECK_INTERVAL_SECONDS:=2}"
: "${GIT_TERMINAL_PROMPT:=0}"
export GIT_TERMINAL_PROMPT
export DATABASE_MIGRATION_URL
LOCK_DIR="$DEPLOY_ROOT/.deploy.lock"
LOCK_ACQUIRED=false
PREVIOUS_RELEASE=""
NEW_RELEASE=""
PREVIOUS_WWW_RELEASE=""
NEW_WWW_RELEASE=""
PREVIOUS_SERVICE_BACKUP=""
ROLLBACK_ARMED=false
APP_SWITCHED=false
SERVICE_SYNC_STARTED=false
WWW_SWITCH_STARTED=false
ASKPASS_FILE=""
cleanup() {
local status=$?
if [[ "$status" -ne 0 && "$ROLLBACK_ARMED" == "true" ]]; then
ROLLBACK_ARMED=false
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_askpass() {
if [[ -z "${GIT_TOKEN:-}" && -z "${GITEA_TOKEN:-}" ]]; then
return 0
fi
local token="${GIT_TOKEN:-${GITEA_TOKEN:-}}"
local username="${GIT_USERNAME:-oauth2}"
ASKPASS_FILE="$(mktemp "${TMPDIR:-/tmp}/tiku-git-askpass.XXXXXX")"
chmod 700 "$ASKPASS_FILE"
cat > "$ASKPASS_FILE" <<'EOF'
#!/usr/bin/env bash
case "$1" in
*Username*) printf '%s\n' "$GIT_ASKPASS_USERNAME" ;;
*Password*) printf '%s\n' "$GIT_ASKPASS_TOKEN" ;;
*) printf '%s\n' "$GIT_ASKPASS_TOKEN" ;;
esac
EOF
export GIT_ASKPASS="$ASKPASS_FILE"
export GIT_ASKPASS_USERNAME="$username"
export GIT_ASKPASS_TOKEN="$token"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
validate_deploy_contract() {
[[ "$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
[[ "$SERVICE_REPO_DIR" != "$RELEASES_DIR" ]] || die "SERVICE_REPO_DIR must not equal RELEASES_DIR"
if [[ "$NODE_ENV" != "production" ]]; then
return 0
fi
truthy "$RUN_TARO_H5_BUILD" || die "Production deployment requires RUN_TARO_H5_BUILD=true"
truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT" \
|| die "Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true"
truthy "$STRICT_H5_RUNTIME_CONFIG" || die "Production deployment requires STRICT_H5_RUNTIME_CONFIG=true"
truthy "$RUN_H5_SMOKE" || die "Production deployment requires RUN_H5_SMOKE=true"
truthy "$RUN_LAUNCH_GATE" || die "Production deployment requires RUN_LAUNCH_GATE=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
[[ -n "$RESTART_COMMAND" || "$SERVICE_MODE" != "none" ]] \
|| die "Production deployment requires a service restart strategy"
[[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL"
if [[ "$SERVICE_MODE" == "systemd" ]]; then
truthy "$SYNC_SERVICE_REPO" || die "Production systemd deployment requires SYNC_SERVICE_REPO=true"
[[ -n "$SERVICE_REPO_DIR" ]] || die "Production systemd deployment requires SERVICE_REPO_DIR"
fi
}
acquire_lock() {
mkdir -p "$DEPLOY_ROOT"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
die "Another deployment appears to be running: $LOCK_DIR"
fi
LOCK_ACQUIRED=true
}
link_shared_env() {
local release="$1"
local env_file="$SHARED_DIR/.env"
if [[ -f "$env_file" ]]; then
ln -sfn "$env_file" "$release/.env"
else
warn "No $env_file found. Production readiness and runtime may fail until it exists."
fi
}
load_runtime_env() {
local env_file="$SHARED_DIR/.env"
[[ -r "$env_file" ]] || die "Missing API runtime config: $env_file"
export_dotenv_if_exists "$env_file"
}
link_runtime_config() {
local source_file="$1"
local target_dir="$2"
local label="$3"
if [[ ! -d "$target_dir" ]]; then
warn "H5 dist directory missing for $label: $target_dir"
return 0
fi
if [[ -f "$source_file" ]]; then
ln -sfn "$source_file" "$target_dir/runtime-config.json"
elif truthy "$STRICT_H5_RUNTIME_CONFIG"; then
die "Missing $label runtime config: $source_file"
else
warn "Missing $label runtime config: $source_file"
fi
}
link_h5_runtime_configs() {
local release="$1"
link_runtime_config "$H5_STUDENT_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-student" "student"
link_runtime_config "$H5_TENANT_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-tenant-admin" "tenant-admin"
link_runtime_config "$H5_PLATFORM_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-platform-admin" "platform-admin"
}
should_run_production_readiness() {
case "$RUN_PRODUCTION_READINESS" in
true|TRUE|1|yes|YES|on|ON) return 0 ;;
false|FALSE|0|no|NO|off|OFF) return 1 ;;
auto)
if [[ "$NODE_ENV" == "production" ]]; then
return 0
fi
if [[ -f "$SHARED_DIR/.env" ]] && grep -Eq '^NODE_ENV=production($|[[:space:]]*)' "$SHARED_DIR/.env"; then
return 0
fi
return 1
;;
*) die "RUN_PRODUCTION_READINESS must be true, false, or auto" ;;
esac
}
run_build_and_checks() {
local release="$1"
cd "$release"
[[ "$NPM_INSTALL_COMMAND" == *"npm ci"* ]] \
|| die "NPM_INSTALL_COMMAND must use npm ci for a locked production install"
[[ "$NPM_INSTALL_COMMAND" == *"--include=dev"* ]] \
|| die "NPM_INSTALL_COMMAND must include Taro build dependencies with --include=dev"
[[ "$NPM_INSTALL_COMMAND" != *"--ignore-scripts"* ]] \
|| die "NPM_INSTALL_COMMAND must allow the reviewed Taro workspace postinstall patches"
run_shell "$NPM_INSTALL_COMMAND"
if truthy "$RUN_CHECKS"; then
while IFS= read -r command_line; do
[[ -z "$command_line" ]] && continue
run_shell "$command_line"
done <<< "$CHECK_COMMANDS"
fi
if truthy "$RUN_API_BUILD"; then
run npm run build:api
fi
if truthy "$RUN_WORKER_BUILD"; then
run npm run build:worker
fi
if truthy "$RUN_TARO_H5_BUILD"; then
if truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT"; then
run npm run audit:taro:supply-chain
fi
run npm run build:taro:h5:student
run npm run build:taro:h5:tenant
run npm run build:taro:h5:platform
link_h5_runtime_configs "$release"
if truthy "$STRICT_H5_RUNTIME_CONFIG"; then
run node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config
run npm run manifest:taro:h5 -- --require-dist --require-runtime-config
else
run node scripts/taro-h5-release-guardrails-test.js --require-dist
run npm run manifest:taro:h5 -- --require-dist
fi
if truthy "$RUN_H5_SMOKE"; then
run npm run smoke:taro:h5
run npm run smoke:taro:h5:interaction
fi
fi
if truthy "$RUN_SECURITY_REPO_SCAN"; then
run npm run security:repo
fi
if truthy "$RUN_RUNTIME_AUDIT"; then
run env NPM_AUDIT_REGISTRY="$NPM_AUDIT_REGISTRY" npm run audit:runtime
fi
link_shared_env "$release"
load_runtime_env
if should_run_production_readiness; then
run npm run readiness:production
fi
if truthy "$RUN_DB_MIGRATIONS"; then
run_shell "$DB_MIGRATION_COMMAND"
fi
if truthy "$RUN_DB_READINESS"; then
log "Running production database readiness after the optional migration step"
run npm run readiness:production:db
fi
if truthy "$RUN_LAUNCH_GATE"; then
[[ -f "$PRODUCTION_LAUNCH_EVIDENCE" ]] || die "Missing production launch evidence: $PRODUCTION_LAUNCH_EVIDENCE"
run env \
DEPLOY_COMMIT_SHA="$(git rev-parse HEAD)" \
DEPLOY_RELEASE_ROOT="$release" \
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE"
fi
}
verify_live_h5_release() {
local release="$1"
truthy "$RUN_LAUNCH_GATE" || return 0
[[ -f "$PRODUCTION_LAUNCH_EVIDENCE" ]] || die "Missing production launch evidence: $PRODUCTION_LAUNCH_EVIDENCE"
log "Verifying the activated H5 release against production URLs"
(
cd "$release"
env \
DEPLOY_COMMIT_SHA="$(git rev-parse HEAD)" \
DEPLOY_RELEASE_ROOT="$release" \
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE" --verify-live-h5
)
}
stage_h5_release() {
local release="$1"
local release_name
release_name="$(basename "$release")"
local staging="$WWW_RELEASES_DIR/.tmp-$release_name"
require_command rsync
rm -rf "$staging"
mkdir -p "$staging/student" "$staging/tenant-admin" "$staging/platform-admin"
run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-student/" "$staging/student/"
run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-tenant-admin/" "$staging/tenant-admin/"
run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-platform-admin/" "$staging/platform-admin/"
NEW_WWW_RELEASE="$WWW_RELEASES_DIR/$release_name"
rm -rf "$NEW_WWW_RELEASE"
mv "$staging" "$NEW_WWW_RELEASE"
}
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
}
switch_www_release() {
[[ -n "$NEW_WWW_RELEASE" ]] || return 0
WWW_SWITCH_STARTED=true
if [[ -L "$WWW_CURRENT_LINK" ]]; then
PREVIOUS_WWW_RELEASE="$(readlink -f "$WWW_CURRENT_LINK")"
elif [[ -d "$WWW_CURRENT_LINK" ]]; then
PREVIOUS_WWW_RELEASE="$WWW_RELEASES_DIR/bootstrap-$(date +%Y%m%d%H%M%S)"
log "Moving existing Web root to $PREVIOUS_WWW_RELEASE"
mv "$WWW_CURRENT_LINK" "$PREVIOUS_WWW_RELEASE"
elif [[ -e "$WWW_CURRENT_LINK" ]]; then
die "WWW root exists but is not a directory or symlink: $WWW_CURRENT_LINK"
fi
atomic_symlink "$NEW_WWW_RELEASE" "$WWW_CURRENT_LINK" \
|| die "$WWW_CURRENT_LINK exists and cannot be replaced by the release symlink"
}
snapshot_service_repo_for_rollback() {
if [[ -n "$PREVIOUS_RELEASE" || ! -d "$SERVICE_REPO_DIR" || -L "$SERVICE_REPO_DIR" ]]; then
return 0
fi
require_command rsync
PREVIOUS_SERVICE_BACKUP="$RELEASES_DIR/bootstrap-service-$(date +%Y%m%d%H%M%S)"
mkdir -p "$PREVIOUS_SERVICE_BACKUP"
run rsync -a --delete --exclude .git "$SERVICE_REPO_DIR/" "$PREVIOUS_SERVICE_BACKUP/"
}
sync_service_repo() {
local release="$1"
truthy "$SYNC_SERVICE_REPO" || return 0
[[ -n "$release" && -d "$release" ]] || return 1
require_command rsync
if [[ -L "$SERVICE_REPO_DIR" ]]; then
[[ "$(readlink -f "$SERVICE_REPO_DIR")" == "$(readlink -f "$release")" ]] \
|| die "SERVICE_REPO_DIR symlink must resolve to the selected current release"
return 0
fi
mkdir -p "$SERVICE_REPO_DIR"
run rsync -a --delete --exclude .git "$release/" "$SERVICE_REPO_DIR/"
}
restart_services() {
if [[ -d "$CURRENT_LINK" ]]; then
cd "$CURRENT_LINK"
elif [[ -d "$SERVICE_REPO_DIR" ]]; then
cd "$SERVICE_REPO_DIR"
else
die "Neither CURRENT_LINK nor SERVICE_REPO_DIR is available for service restart"
fi
if [[ -n "$RESTART_COMMAND" ]]; then
run_shell "$RESTART_COMMAND" || return 1
return 0
fi
case "$SERVICE_MODE" in
none)
warn "SERVICE_MODE=none; release switched but no service was restarted."
;;
systemd)
[[ -n "$SYSTEMD_UNITS" ]] || die "SYSTEMD_UNITS is required when SERVICE_MODE=systemd"
for unit in $SYSTEMD_UNITS; do
run systemctl restart "$unit" || return 1
done
for unit in $SYSTEMD_UNITS; do
run systemctl is-active --quiet "$unit" || return 1
done
;;
pm2)
require_command pm2
if [[ -n "$PM2_ECOSYSTEM" ]]; then
run pm2 startOrReload "$PM2_ECOSYSTEM" --update-env || return 1
elif [[ -n "$PM2_PROCESS_NAMES" ]]; then
for name in $PM2_PROCESS_NAMES; do
run pm2 restart "$name" --update-env || return 1
done
else
die "PM2_ECOSYSTEM or PM2_PROCESS_NAMES is required when SERVICE_MODE=pm2"
fi
;;
compose)
require_command docker
[[ -n "$COMPOSE_FILE" ]] || die "COMPOSE_FILE is required when SERVICE_MODE=compose"
run docker compose -f "$COMPOSE_FILE" up -d --build || return 1
;;
*)
die "Unknown SERVICE_MODE: $SERVICE_MODE"
;;
esac
}
healthcheck() {
if [[ -z "$HEALTHCHECK_URL" ]]; then
return 0
fi
require_command curl
local deadline=$((SECONDS + HEALTHCHECK_TIMEOUT_SECONDS))
log "Waiting for healthcheck: $HEALTHCHECK_URL"
while (( SECONDS < deadline )); do
if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
log "Healthcheck passed."
return 0
fi
sleep "$HEALTHCHECK_INTERVAL_SECONDS"
done
return 1
}
switch_current() {
local release="$1"
if [[ -e "$CURRENT_LINK" && ! -L "$CURRENT_LINK" ]]; then
die "$CURRENT_LINK exists and is not a symlink; refusing to replace it"
fi
if [[ -L "$CURRENT_LINK" ]]; then
PREVIOUS_RELEASE="$(readlink -f "$CURRENT_LINK")"
fi
atomic_symlink "$release" "$CURRENT_LINK" || die "Failed to switch $CURRENT_LINK"
}
rollback() {
local rollback_failed=false
if [[ "$WWW_SWITCH_STARTED" == "true" && -n "$PREVIOUS_WWW_RELEASE" ]]; then
warn "Rolling Web root back to $PREVIOUS_WWW_RELEASE"
atomic_symlink "$PREVIOUS_WWW_RELEASE" "$WWW_CURRENT_LINK" || rollback_failed=true
elif [[ "$WWW_SWITCH_STARTED" == "true" && -n "$NEW_WWW_RELEASE" ]]; then
warn "No previous Web release recorded; restoring an absent Web root."
rm -f "$WWW_CURRENT_LINK" || rollback_failed=true
fi
local service_rollback_release="$PREVIOUS_RELEASE"
if [[ -z "$service_rollback_release" ]]; then
service_rollback_release="$PREVIOUS_SERVICE_BACKUP"
fi
if [[ "$APP_SWITCHED" == "true" && -n "$PREVIOUS_RELEASE" ]]; then
warn "Rolling application release back to $PREVIOUS_RELEASE"
atomic_symlink "$PREVIOUS_RELEASE" "$CURRENT_LINK" || rollback_failed=true
elif [[ "$APP_SWITCHED" == "true" && -n "$NEW_RELEASE" ]]; then
warn "No previous application current release recorded; restoring an absent current link."
rm -f "$CURRENT_LINK" || rollback_failed=true
fi
if [[ "$SERVICE_SYNC_STARTED" == "true" ]] && truthy "$SYNC_SERVICE_REPO" && [[ -n "$service_rollback_release" ]]; then
sync_service_repo "$service_rollback_release" || rollback_failed=true
fi
if [[ "$SERVICE_SYNC_STARTED" == "true" ]]; then
restart_services || rollback_failed=true
healthcheck || rollback_failed=true
fi
[[ "$rollback_failed" == "false" ]]
}
prune_releases() {
local keep="$1"
local directory="${2:-$RELEASES_DIR}"
[[ "$keep" =~ ^[0-9]+$ ]] || return 0
(( keep > 0 )) || return 0
find "$directory" -mindepth 1 -maxdepth 1 -type d ! -name '.*' ! -name 'bootstrap-*' -print \
| sort -r \
| tail -n +"$((keep + 1))" \
| while IFS= read -r old_release; do
if [[ "$old_release" == "$NEW_RELEASE" || "$old_release" == "$PREVIOUS_RELEASE" \
|| "$old_release" == "$NEW_WWW_RELEASE" || "$old_release" == "$PREVIOUS_WWW_RELEASE" ]]; then
continue
fi
log "Pruning old release: $old_release"
rm -rf "$old_release"
done
}
main() {
require_command git
require_command npm
acquire_lock
validate_deploy_contract
mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$WWW_RELEASES_DIR"
prepare_askpass
local timestamp
timestamp="$(date +%Y%m%d%H%M%S)"
local tmp_release="$RELEASES_DIR/.tmp-$timestamp"
log "Cloning $REPO_URL#$BRANCH"
run git clone --depth "$GIT_DEPTH" --branch "$BRANCH" "$REPO_URL" "$tmp_release"
local commit
commit="$(git -C "$tmp_release" rev-parse --short=12 HEAD)"
NEW_RELEASE="$RELEASES_DIR/$timestamp-$commit"
mv "$tmp_release" "$NEW_RELEASE"
log "Building release $NEW_RELEASE"
run_build_and_checks "$NEW_RELEASE"
log "Staging H5 release for $WWW_ROOT"
stage_h5_release "$NEW_RELEASE"
if [[ -L "$CURRENT_LINK" ]]; then
PREVIOUS_RELEASE="$(readlink -f "$CURRENT_LINK")"
fi
snapshot_service_repo_for_rollback
ROLLBACK_ARMED=true
log "Switching current release to $NEW_RELEASE"
switch_current "$NEW_RELEASE"
APP_SWITCHED=true
log "Synchronizing service runtime to $SERVICE_REPO_DIR"
SERVICE_SYNC_STARTED=true
if ! sync_service_repo "$NEW_RELEASE"; then
ROLLBACK_ARMED=false
rollback || true
die "Service runtime synchronization failed; rollback attempted."
fi
if ! restart_services; then
ROLLBACK_ARMED=false
rollback || true
die "Service restart failed; rollback attempted."
fi
if ! healthcheck; then
ROLLBACK_ARMED=false
rollback || true
die "Healthcheck failed; rollback attempted."
fi
log "Switching Web root to $NEW_WWW_RELEASE"
switch_www_release
verify_live_h5_release "$NEW_RELEASE"
ROLLBACK_ARMED=false
prune_releases "$KEEP_RELEASES" "$RELEASES_DIR"
prune_releases "$KEEP_RELEASES" "$WWW_RELEASES_DIR"
log "Deploy complete: $APP_NAME @ $commit"
}
main "$@"