#!/usr/bin/env bash set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" HOST="${STUDIO_HOST:-58.38.22.103}" REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}" STABLE_DIR="${REMOTE_ROOT}/Memind" CANDIDATE_BASE="${REMOTE_ROOT}/Memind-candidates" INCOMING_DIR="${REMOTE_ROOT}/incoming/memind-portal-canary" BACKUP_DIR="${REMOTE_ROOT}/backups/memind" GOOSED_DIR="${REMOTE_ROOT}/goosed-prod" RUNTIME_ROOT="${ROOT}/.runtime/portal" PORTAL_CANDIDATE_LABEL="cn.tkmind.memind-portal-candidate" CANARY_PROXY_LABEL="cn.tkmind.memind-canary-proxy" CANARY_TUNNEL_LABEL="cn.tkmind.memind-canary-tunnel" DEEPSEEK_COMPAT_LABEL="cn.tkmind.memind-deepseek-compat-candidate" EDGE_HOST="${MEMIND_CANARY_EDGE_HOST:-ssh105-public}" CANARY_PROXY_PORT=18082 CANARY_TUNNEL_REMOTE_PORT=19082 DEEPSEEK_COMPAT_PORT=18036 EDGE_MOBILE_CONFIG="/etc/nginx/conf.d/m.tkmind.cn.conf" EDGE_WECHAT_CONFIG="/etc/nginx/conf.d/wechat.m.tkmind.cn.conf" CANARY_USERNAMES="john" CANARY_WECHAT_USER_IDS="wx_ul610et8" RELEASE_TS="$(date +%Y%m%d-%H%M%S)" FULL_SHA="$(git -C "${ROOT}" rev-parse HEAD)" SHORT_SHA="${FULL_SHA:0:7}" RELEASE_ID="${RELEASE_TS}-${SHORT_SHA}" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/memind-portal-canary-release.XXXXXX")" BUNDLE_PATH="${TMP_DIR}/memind-portal-canary-${RELEASE_ID}.tar.gz" MANIFEST_PATH="${TMP_DIR}/memind-portal-canary-${RELEASE_ID}.manifest.txt" SHA_PATH="${TMP_DIR}/memind-portal-canary-${RELEASE_ID}.sha256" DRY_RUN=0 AUTO_YES=0 cleanup() { rm -rf "${TMP_DIR}" } trap cleanup EXIT usage() { cat <<'EOF' Usage: bash scripts/release-portal-canary-prod.sh [--dry-run] [--yes] Deploys a candidate Portal beside the stable 103 runtime: stable Portal 127.0.0.1:8081 canary router 127.0.0.1:18082 candidate Portal 127.0.0.1:18081 candidate goosed 127.0.0.1:18015 DeepSeek compat 0.0.0.0:18036 (host/container only) The stable runtime is not replaced. A dedicated reverse tunnel exposes the router only to 105 at 127.0.0.1:19082. The committed release workflow updates the two active 105 nginx upstreams only after backups, candidate health, and immutable identity checks pass, then proves automatic fallback before success. EOF } while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1 ;; --yes|-y) AUTO_YES=1 ;; -h|--help) usage exit 0 ;; *) echo "Unknown argument: $1" >&2 usage >&2 exit 1 ;; esac shift done for bypass in \ ALLOW_PORTAL_RELEASE_SCOPE_BYPASS \ ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES \ MEMIND_RELEASE_SKIP_GATE; do if [[ "${!bypass:-0}" == "1" ]]; then echo "Production canary release forbids ${bypass}." >&2 exit 1 fi done say() { printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*" } need_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "Missing command: $1" >&2 exit 1 } } need_cmd ssh need_cmd scp need_cmd tar need_cmd shasum branch="$(git -C "${ROOT}" branch --show-current)" if [[ "${branch}" != "main" ]]; then echo "Canary production release requires main, got: ${branch:-detached}" >&2 exit 1 fi git -C "${ROOT}" fetch origin --prune if [[ "$(git -C "${ROOT}" rev-parse HEAD)" != "$(git -C "${ROOT}" rev-parse origin/main)" ]]; then echo "Canary production release requires HEAD to equal origin/main." >&2 exit 1 fi ALLOW_MAIN_RELEASE=1 bash "${ROOT}/scripts/check-release-ready.sh" --skip-fetch say "Run release source guards" ( cd "${ROOT}" npm test -- --test-name-pattern='publish|space|billing' >/dev/null npm run verify:mindspace-publish-guards >/dev/null npm run verify:mindspace-page-sync-guards >/dev/null npm run verify:h5-session-patches >/dev/null npm run verify:page-data >/dev/null npm run check:mindspace-public-links >/dev/null ) required_runtime_paths=( server.mjs memind-canary-proxy.mjs deepseek-no-think-proxy.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs tkmind-search-mcp.mjs tkmind-excel-mcp.mjs dist package.json scripts/run-memind-portal-candidate.sh scripts/run-memind-canary-proxy-prod.sh scripts/run-deepseek-compat-proxy-candidate.sh scripts/goosed-canary.compose.yml scripts/memind-portal-tunnel.sh ) for relative in "${required_runtime_paths[@]}"; do [[ -e "${RUNTIME_ROOT}/${relative}" ]] || { echo "Candidate runtime is missing ${relative}; rebuild and rerun the complete Gate." >&2 exit 1 } done say "Verify the complete Gate report against the exact candidate artifact" node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" say "Package the verified candidate artifact" tar -czf "${BUNDLE_PATH}" -C "${RUNTIME_ROOT}" . bundle_sha="$(shasum -a 256 "${BUNDLE_PATH}" | awk '{print $1}')" printf '%s %s\n' "${bundle_sha}" "$(basename "${BUNDLE_PATH}")" > "${SHA_PATH}" { echo "release_id=${RELEASE_ID}" echo "created_at=$(date '+%Y-%m-%d %H:%M:%S %z')" echo "git_head=${FULL_SHA}" echo "git_branch=${branch}" echo "artifact_tree=.runtime/portal" echo "artifact_bundle_sha256=${bundle_sha}" echo "routing=stable:8081,proxy:18082,edge-tunnel:19082,candidate:18081,goosed-canary:18015,deepseek-compat:18036" echo "canary_usernames=${CANARY_USERNAMES}" echo "canary_wechat_user_ids=${CANARY_WECHAT_USER_IDS}" echo "manual_environment_changes=none" } > "${MANIFEST_PATH}" if [[ "${DRY_RUN}" -eq 1 ]]; then say "Dry-run complete; no 103 connection or production write occurred" ls -lh "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" exit 0 fi say "Run 103 read-only preflight" ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \ "test -d '${STABLE_DIR}' \ && test -f '${STABLE_DIR}/.env' \ && test -f '${GOOSED_DIR}/docker-compose.prod.yml' \ && curl -fsS http://127.0.0.1:8081/api/status >/dev/null \ && ! lsof -nP -iTCP:${CANARY_PROXY_PORT} -sTCP:LISTEN >/dev/null 2>&1 \ && ! lsof -nP -iTCP:${DEEPSEEK_COMPAT_PORT} -sTCP:LISTEN >/dev/null 2>&1 \ && ! lsof -nP -iTCP:18081 -sTCP:LISTEN >/dev/null 2>&1 \ && ! lsof -nP -iTCP:18015 -sTCP:LISTEN >/dev/null 2>&1 \ && /opt/homebrew/bin/docker inspect goosed-prod-1 >/dev/null \ && test \"\$(df -Pk '${REMOTE_ROOT}' | awk 'NR==2 {print \$4}')\" -gt 10485760 \ && ssh -o BatchMode=yes -o ConnectTimeout=10 '${EDGE_HOST}' \ \"test -f '${EDGE_MOBILE_CONFIG}' \ && test -f '${EDGE_WECHAT_CONFIG}' \ && grep -q 'proxy_pass http://58.38.22.103:8081;' '${EDGE_MOBILE_CONFIG}' \ && grep -q 'proxy_pass http://58.38.22.103:8081;' '${EDGE_WECHAT_CONFIG}' \ && ! grep -q 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' '${EDGE_MOBILE_CONFIG}' \ && ! grep -q 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' '${EDGE_WECHAT_CONFIG}' \ && nginx -t >/dev/null 2>&1 \ && ! lsof -nP -iTCP:${CANARY_TUNNEL_REMOTE_PORT} -sTCP:LISTEN >/dev/null 2>&1\"" if [[ "${AUTO_YES}" -ne 1 ]]; then say "Production canary confirmation" echo "Target host: ${HOST}" echo "Release: ${RELEASE_ID}" echo "Commit: ${FULL_SHA}" echo "Stable remains on 8081; 105 will use the isolated 19082 canary tunnel." echo "Selected immutable identities route to candidate 18081." read -r -p "Continue with the 103 canary release? [y/N] " confirm /dev/null || true )" if [[ "${code}" == "200" ]] \ && grep -qi '^x-memind-runtime-role: candidate' "${headers}"; then rm -f "${headers}" return 0 fi rm -f "${headers}" return 1 } deepseek_compat_healthy() { curl -fsS --max-time 5 "http://127.0.0.1:${DEEPSEEK_COMPAT_PORT}/health" \ | /opt/homebrew/opt/node@24/bin/node --input-type=module -e ' let input = ""; process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { const status = JSON.parse(input); if ( status.ok !== true || status.contractVersion !== 1 || status.deepseekThinking !== "disabled" ) process.exit(1); }); ' } edge_ssh() { # This remote release script itself arrives on stdin. Ordinary nested SSH # commands must not consume the unparsed remainder of that script. ssh -n -o BatchMode=yes -o ConnectTimeout=10 "${EDGE_HOST}" "$@" } write_canary_tunnel_plist() { cat > "${CANARY_TUNNEL_PLIST}" < Label${CANARY_TUNNEL_LABEL} ProgramArguments ${STABLE_DIR}/scripts/memind-portal-tunnel.sh EnvironmentVariables MEMIND_PORTAL_TUNNEL_HOST${EDGE_HOST} MEMIND_PORTAL_TUNNEL_LOCAL_PORT${CANARY_PROXY_PORT} MEMIND_PORTAL_TUNNEL_REMOTE_PORT${CANARY_TUNNEL_REMOTE_PORT} RunAtLoad KeepAlive StandardOutPath${HOME}/Library/Logs/memind-canary-tunnel.log StandardErrorPath${HOME}/Library/Logs/memind-canary-tunnel.log EOF launchctl bootout "${LAUNCHD_GUI}/${CANARY_TUNNEL_LABEL}" >/dev/null 2>&1 || true launchctl bootstrap "${LAUNCHD_GUI}" "${CANARY_TUNNEL_PLIST}" >/dev/null launchctl enable "${LAUNCHD_GUI}/${CANARY_TUNNEL_LABEL}" >/dev/null 2>&1 || true launchctl kickstart -k "${LAUNCHD_GUI}/${CANARY_TUNNEL_LABEL}" >/dev/null } restore_edge_to_stable() { edge_ssh \ "set -euo pipefail mobile_backup_exists=0 wechat_backup_exists=0 [[ -f '${EDGE_MOBILE_BACKUP}' ]] && mobile_backup_exists=1 [[ -f '${EDGE_WECHAT_BACKUP}' ]] && wechat_backup_exists=1 [[ \"\${mobile_backup_exists}\" == \"\${wechat_backup_exists}\" ]] if [[ \"\${mobile_backup_exists}\" -eq 1 ]]; then test -f '${EDGE_MOBILE_BACKUP}.sha256' sha256sum -c '${EDGE_MOBILE_BACKUP}.sha256' >/dev/null cp '${EDGE_MOBILE_BACKUP}' '${EDGE_MOBILE_CONFIG}' cp '${EDGE_WECHAT_BACKUP}' '${EDGE_WECHAT_CONFIG}' fi grep -q 'proxy_pass http://58.38.22.103:8081;' '${EDGE_MOBILE_CONFIG}' grep -q 'proxy_pass http://58.38.22.103:8081;' '${EDGE_WECHAT_CONFIG}' ! grep -q 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' '${EDGE_MOBILE_CONFIG}' ! grep -q 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' '${EDGE_WECHAT_CONFIG}' nginx -t >/dev/null systemctl reload nginx curl -kfsS --max-time 15 \ --resolve m.tkmind.cn:443:127.0.0.1 \ https://m.tkmind.cn/api/status >/dev/null curl -kfsS --max-time 15 \ --resolve wechat.m.tkmind.cn:443:127.0.0.1 \ https://wechat.m.tkmind.cn/api/status >/dev/null" } stop_candidate_services() { launchctl bootout "${LAUNCHD_GUI}/${CANARY_TUNNEL_LABEL}" >/dev/null 2>&1 || true launchctl bootout "${LAUNCHD_GUI}/${CANARY_PROXY_LABEL}" >/dev/null 2>&1 || true launchctl bootout "${LAUNCHD_GUI}/${PORTAL_CANDIDATE_LABEL}" >/dev/null 2>&1 || true launchctl bootout "${LAUNCHD_GUI}/${DEEPSEEK_COMPAT_LABEL}" >/dev/null 2>&1 || true "${DOCKER_BIN}" rm -f goosed-prod-canary >/dev/null 2>&1 || true } rollback() { local status=$? trap - ERR say "Canary rollout failed; restoring the direct stable edge upstream" rm -f "${STABLE_DIR}/.release-drain" if restore_edge_to_stable >/dev/null 2>&1; then stop_candidate_services else say "CRITICAL: edge restore could not be verified; keeping canary services running" say "Run scripts/rollback-portal-canary-prod.sh after checking the 105 nginx configs" fi exit "${status}" } trap rollback ERR [[ -f "${BUNDLE}" && -f "${MANIFEST}" && -f "${SHA_FILE}" ]] cd "${INCOMING_DIR}" shasum -a 256 -c "$(basename "${SHA_FILE}")" say "Create and verify the full stable backup" COPYFILE_DISABLE=1 tar --exclude='Memind/.tailscale/*.sock' \ -czf "${FULL_BACKUP_TAR}" -C "$(dirname "${STABLE_DIR}")" "$(basename "${STABLE_DIR}")" shasum -a 256 "${FULL_BACKUP_TAR}" > "${FULL_BACKUP_TAR}.sha256" shasum -a 256 -c "${FULL_BACKUP_TAR}.sha256" gzip -t "${FULL_BACKUP_TAR}" tar -tzf "${FULL_BACKUP_TAR}" | grep '^Memind/.env$' >/dev/null say "Create and verify the persisted-data backup" persist_tmp="$(mktemp -d "${TMPDIR:-/tmp}/memind-canary-persist.XXXXXX")" for item in .env MindSpace data users .tailscale public/plaza-covers logs; do if [[ -e "${STABLE_DIR}/${item}" ]]; then mkdir -p "${persist_tmp}/$(dirname "${item}")" if [[ "${item}" == ".tailscale" ]]; then mkdir -p "${persist_tmp}/${item}" ( cd "${STABLE_DIR}/${item}" COPYFILE_DISABLE=1 tar --exclude='*.sock' -cf - . ) | ( cd "${persist_tmp}/${item}" COPYFILE_DISABLE=1 tar -xf - ) else cp -a "${STABLE_DIR}/${item}" "${persist_tmp}/$(dirname "${item}")/" fi fi done COPYFILE_DISABLE=1 tar -czf "${PERSIST_BACKUP_TAR}" -C "${persist_tmp}" . rm -rf "${persist_tmp}" shasum -a 256 "${PERSIST_BACKUP_TAR}" > "${PERSIST_BACKUP_TAR}.sha256" shasum -a 256 -c "${PERSIST_BACKUP_TAR}.sha256" gzip -t "${PERSIST_BACKUP_TAR}" tar -tzf "${PERSIST_BACKUP_TAR}" | grep -E '^(\./)?\.env$' >/dev/null say "Create and verify the active 105 nginx routing backup" edge_ssh \ "set -euo pipefail test -f '${EDGE_MOBILE_CONFIG}' test -f '${EDGE_WECHAT_CONFIG}' grep -q 'proxy_pass http://58.38.22.103:8081;' '${EDGE_MOBILE_CONFIG}' grep -q 'proxy_pass http://58.38.22.103:8081;' '${EDGE_WECHAT_CONFIG}' ! grep -q 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' '${EDGE_MOBILE_CONFIG}' ! grep -q 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' '${EDGE_WECHAT_CONFIG}' cp '${EDGE_MOBILE_CONFIG}' '${EDGE_MOBILE_BACKUP}' cp '${EDGE_WECHAT_CONFIG}' '${EDGE_WECHAT_BACKUP}' sha256sum '${EDGE_MOBILE_BACKUP}' '${EDGE_WECHAT_BACKUP}' \ > '${EDGE_MOBILE_BACKUP}.sha256' sha256sum -c '${EDGE_MOBILE_BACKUP}.sha256' nginx -t >/dev/null" say "Extract the source-free candidate runtime" rm -rf "${CANDIDATE_DIR}" mkdir -p "${CANDIDATE_DIR}" tar -xzf "${BUNDLE}" -C "${CANDIDATE_DIR}" cp "${MANIFEST}" "${CANDIDATE_DIR}/.release-manifest.txt" chmod 755 \ "${CANDIDATE_DIR}/scripts/run-memind-portal-candidate.sh" \ "${CANDIDATE_DIR}/scripts/run-memind-canary-proxy-prod.sh" \ "${CANDIDATE_DIR}/scripts/run-deepseek-compat-proxy-candidate.sh" say "Stop an older canary without touching stable Portal 8081" restore_edge_to_stable stop_candidate_services say "Start an isolated goosed candidate on 18015" ( cd "${GOOSED_DIR}" set -a # shellcheck disable=SC1091 source .env set +a export CANDIDATE_RUNTIME_DIR="${CANDIDATE_DIR}" "${DOCKER_BIN}" compose -p goosed-prod \ -f "${GOOSED_COMPOSE}" \ -f "${CANDIDATE_DIR}/scripts/goosed-canary.compose.yml" \ up -d --no-deps goosed-canary ) for _ in $(seq 1 60); do if [[ "$(curl -skS -m 5 https://127.0.0.1:18015/status 2>/dev/null || true)" == "ok" ]]; then break fi sleep 2 done [[ "$(curl -skS -m 5 https://127.0.0.1:18015/status 2>/dev/null || true)" == "ok" ]] "${DOCKER_BIN}" exec goosed-prod-canary \ sh -lc 'test -x /usr/local/bin/node && test -f /opt/portal/mindspace-sandbox-mcp.mjs' say "Start the DeepSeek tool-round compatibility proxy on 18036" cat > "${DEEPSEEK_COMPAT_PLIST}" < Label${DEEPSEEK_COMPAT_LABEL} ProgramArguments ${CANDIDATE_DIR}/scripts/run-deepseek-compat-proxy-candidate.sh EnvironmentVariables MEMIND_CANARY_STABLE_ROOT${STABLE_DIR} MEMIND_CANARY_DEEPSEEK_PROXY_PORT${DEEPSEEK_COMPAT_PORT} RunAtLoad KeepAlive ThrottleInterval5 StandardOutPath${HOME}/Library/Logs/memind-deepseek-compat-candidate.log StandardErrorPath${HOME}/Library/Logs/memind-deepseek-compat-candidate.log EOF launchctl bootstrap "${LAUNCHD_GUI}" "${DEEPSEEK_COMPAT_PLIST}" >/dev/null launchctl kickstart -k "${LAUNCHD_GUI}/${DEEPSEEK_COMPAT_LABEL}" >/dev/null for _ in $(seq 1 30); do deepseek_compat_healthy && break sleep 1 done deepseek_compat_healthy "${DOCKER_BIN}" exec goosed-prod-canary \ sh -lc "curl -fsS --max-time 5 http://host.docker.internal:${DEEPSEEK_COMPAT_PORT}/health \ | grep -q '\"deepseekThinking\":\"disabled\"'" say "Start the passive candidate Portal on 18081" cat > "${CANDIDATE_PLIST}" < Label${PORTAL_CANDIDATE_LABEL} ProgramArguments ${CANDIDATE_DIR}/scripts/run-memind-portal-candidate.sh EnvironmentVariables MEMIND_CANARY_STABLE_ROOT${STABLE_DIR} MEMIND_CANARY_RELEASE_ID${RELEASE_ID} MEMIND_CANARY_CANDIDATE_PORT18081 MEMIND_CANARY_GOOSED_URLhttps://127.0.0.1:18015 MEMIND_CANARY_DEEPSEEK_PROXY_PORT${DEEPSEEK_COMPAT_PORT} RunAtLoad KeepAlive ThrottleInterval5 StandardOutPath${HOME}/Library/Logs/memind-portal-candidate.log StandardErrorPath${HOME}/Library/Logs/memind-portal-candidate.log EOF launchctl bootstrap "${LAUNCHD_GUI}" "${CANDIDATE_PLIST}" >/dev/null launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_CANDIDATE_LABEL}" >/dev/null for _ in $(seq 1 60); do candidate_healthy && break sleep 2 done candidate_healthy say "Validate immutable canary selectors against the production identity database" mkdir -p "$(dirname "${SECRET_FILE}")" if [[ ! -s "${SECRET_FILE}" ]]; then umask 077 /usr/bin/openssl rand -hex 32 > "${SECRET_FILE}" fi chmod 600 "${SECRET_FILE}" ( set -a # shellcheck disable=SC1091 source "${STABLE_DIR}/.env" set +a export MEMIND_PORTAL_H5_ROOT="${STABLE_DIR}" export MEMIND_RELEASE_CANARY_USERNAMES="${CANARY_USERNAMES}" export MEMIND_RELEASE_CANARY_WECHAT_USER_IDS="${CANARY_WECHAT_USER_IDS}" /opt/homebrew/opt/node@24/bin/node "${CANDIDATE_DIR}/memind-canary-proxy.mjs" --check ) say "Start the fail-closed identity router on 18082" cat > "${PROXY_PLIST}" < Label${CANARY_PROXY_LABEL} ProgramArguments ${CANDIDATE_DIR}/scripts/run-memind-canary-proxy-prod.sh EnvironmentVariables MEMIND_CANARY_STABLE_ROOT${STABLE_DIR} MEMIND_CANARY_SECRET_FILE${SECRET_FILE} MEMIND_CANARY_PROXY_PORT${CANARY_PROXY_PORT} MEMIND_RELEASE_CANARY_USERNAMES${CANARY_USERNAMES} MEMIND_RELEASE_CANARY_WECHAT_USER_IDS${CANARY_WECHAT_USER_IDS} MEMIND_CANARY_CANDIDATE_HEALTH_URLShttp://127.0.0.1:${DEEPSEEK_COMPAT_PORT}/health RunAtLoad KeepAlive ThrottleInterval5 StandardOutPath${HOME}/Library/Logs/memind-canary-proxy.log StandardErrorPath${HOME}/Library/Logs/memind-canary-proxy.log EOF launchctl bootstrap "${LAUNCHD_GUI}" "${PROXY_PLIST}" >/dev/null launchctl kickstart -k "${LAUNCHD_GUI}/${CANARY_PROXY_LABEL}" >/dev/null sleep 2 diagnostic_secret="$(tr -d '\r\n' < "${SECRET_FILE}")" diagnostic="$( curl -fsS \ -H "X-Memind-Canary-Secret: ${diagnostic_secret}" \ "http://127.0.0.1:${CANARY_PROXY_PORT}/__memind_canary/health" )" printf '%s' "${diagnostic}" | /opt/homebrew/opt/node@24/bin/node --input-type=module -e ' let input = ""; process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { const status = JSON.parse(input); if (!status.ok || !status.stable || !status.candidate) process.exit(1); if (status.policy.usernames < 1 || status.policy.wechatUserIds < 1) process.exit(1); }); ' for probe_query in 'username=john' 'wechat_user_id=wx_ul610et8'; do route_probe="$( curl -fsS \ -H "X-Memind-Canary-Secret: ${diagnostic_secret}" \ "http://127.0.0.1:${CANARY_PROXY_PORT}/__memind_canary/route-probe?${probe_query}" )" printf '%s' "${route_probe}" | /opt/homebrew/opt/node@24/bin/node --input-type=module -e ' let input = ""; process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { const status = JSON.parse(input); if (status.configuredTarget !== "candidate") process.exit(1); if (status.candidate !== true || status.route !== "candidate") process.exit(1); }); ' done say "Start the isolated 105 reverse tunnel on 19082" write_canary_tunnel_plist for _ in $(seq 1 30); do edge_code="$( edge_ssh \ "curl -sS -o /dev/null -w '%{http_code}' \ --max-time 5 http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT}/api/status" \ 2>/dev/null || true )" [[ "${edge_code}" == "200" ]] && break sleep 2 done [[ "${edge_code:-}" == "200" ]] say "Drain active Agent runs for the bounded edge switch" touch "${STABLE_DIR}/.release-drain" deadline=$(( $(date +%s) + 120 )) while (( $(date +%s) < deadline )); do queue="$( /opt/homebrew/opt/node@24/bin/node \ "${STABLE_DIR}/scripts/agent-run-worker.mjs" --status 2>/dev/null || true )" counts="$( printf '%s' "${queue}" | /opt/homebrew/opt/node@24/bin/node --input-type=module -e ' let input = ""; process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { try { const queue = JSON.parse(input).queue || {}; console.log(`${Number(queue.inFlight || 0)} ${Number(queue.pendingDispatches || 0)}`); } catch { console.log("unknown unknown"); } }); ' )" if [[ "${counts}" == "0 0" ]]; then break fi sleep 2 done [[ "${counts:-unknown unknown}" == "0 0" ]] say "Switch the committed 105 nginx upstreams to the isolated canary tunnel" # Unlike ordinary edge_ssh calls, this command intentionally receives only the # bounded EDGE_SWITCH heredoc on stdin. ssh -o BatchMode=yes -o ConnectTimeout=10 "${EDGE_HOST}" /bin/bash < "\${temp_config}" after_count="\$(grep -cF 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' "\${temp_config}")" [[ "\${after_count}" == "\${before_count}" ]] chown root:root "\${temp_config}" chmod 0644 "\${temp_config}" mv "\${temp_config}" "\${config}" done nginx -t systemctl reload nginx curl -fsS --max-time 10 http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT}/api/status >/dev/null curl -kfsS --max-time 15 \ --resolve m.tkmind.cn:443:127.0.0.1 \ https://m.tkmind.cn/api/status >/dev/null curl -kfsS --max-time 15 \ --resolve wechat.m.tkmind.cn:443:127.0.0.1 \ https://wechat.m.tkmind.cn/api/status >/dev/null EDGE_SWITCH rm -f "${STABLE_DIR}/.release-drain" say "Verify candidate failure automatically falls back to stable" launchctl bootout "${LAUNCHD_GUI}/${PORTAL_CANDIDATE_LABEL}" >/dev/null for _ in $(seq 1 20); do if ! curl -fsS http://127.0.0.1:18081/api/status >/dev/null 2>&1; then break fi sleep 1 done fallback_probe="$( curl -fsS \ -H "X-Memind-Canary-Secret: ${diagnostic_secret}" \ "http://127.0.0.1:${CANARY_PROXY_PORT}/__memind_canary/route-probe?username=john" )" printf '%s' "${fallback_probe}" | /opt/homebrew/opt/node@24/bin/node --input-type=module -e ' let input = ""; process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { const status = JSON.parse(input); if (status.configuredTarget !== "candidate") process.exit(1); if (status.candidate !== false || status.route !== "stable") process.exit(1); }); ' edge_ssh \ "curl -fsS --max-time 10 \ http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT}/api/status >/dev/null" launchctl bootstrap "${LAUNCHD_GUI}" "${CANDIDATE_PLIST}" >/dev/null launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_CANDIDATE_LABEL}" >/dev/null for _ in $(seq 1 60); do candidate_healthy && break sleep 2 done candidate_healthy final_diagnostic="$( curl -fsS \ -H "X-Memind-Canary-Secret: ${diagnostic_secret}" \ "http://127.0.0.1:${CANARY_PROXY_PORT}/__memind_canary/health" )" printf '%s' "${final_diagnostic}" | /opt/homebrew/opt/node@24/bin/node --input-type=module -e ' let input = ""; process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { const status = JSON.parse(input); if (!status.ok || !status.stable || !status.candidate) process.exit(1); }); ' trap - ERR say "103 production canary is active" printf 'release_id=%s\n' "${RELEASE_ID}" printf 'candidate_dir=%s\n' "${CANDIDATE_DIR}" printf 'full_backup=%s\n' "${FULL_BACKUP_TAR}" printf 'persist_backup=%s\n' "${PERSIST_BACKUP_TAR}" printf 'edge_mobile_backup=%s:%s\n' "${EDGE_HOST}" "${EDGE_MOBILE_BACKUP}" printf 'edge_wechat_backup=%s:%s\n' "${EDGE_HOST}" "${EDGE_WECHAT_BACKUP}" printf 'stable_health=http://127.0.0.1:8081/api/status\n' printf 'proxy_health=http://127.0.0.1:%s/__memind_canary/health\n' "${CANARY_PROXY_PORT}" printf 'edge_tunnel=http://127.0.0.1:%s/api/status\n' "${CANARY_TUNNEL_REMOTE_PORT}" printf 'candidate_health=http://127.0.0.1:18081/api/status\n' printf 'candidate_goosed=https://127.0.0.1:18015/status\n' printf 'deepseek_compat_health=http://127.0.0.1:%s/health\n' "${DEEPSEEK_COMPAT_PORT}" REMOTE_SCRIPT say "103 canary release completed" printf 'release_id=%s\n' "${RELEASE_ID}" printf 'git_head=%s\n' "${FULL_SHA}"