Files
memind/scripts/release-portal-canary-prod.sh
T
john fb6865638a
Memind CI / Test, build, and release guards (pull_request) Successful in 1m32s
fix: align DeepSeek canary tool rounds with gate
2026-07-26 23:49:32 +08:00

746 lines
28 KiB
Bash
Executable File

#!/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/tty
[[ "${confirm}" =~ ^[Yy]$ ]] || exit 0
fi
say "Upload the verified candidate bundle"
ssh -o BatchMode=yes "${HOST}" "mkdir -p '${INCOMING_DIR}' '${BACKUP_DIR}' '${CANDIDATE_BASE}'"
scp -q "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" "${HOST}:${INCOMING_DIR}/"
say "Install the isolated candidate and identity router on 103"
ssh -o BatchMode=yes "${HOST}" \
"RELEASE_ID='${RELEASE_ID}' \
STABLE_DIR='${STABLE_DIR}' \
CANDIDATE_BASE='${CANDIDATE_BASE}' \
INCOMING_DIR='${INCOMING_DIR}' \
BACKUP_DIR='${BACKUP_DIR}' \
GOOSED_DIR='${GOOSED_DIR}' \
PORTAL_CANDIDATE_LABEL='${PORTAL_CANDIDATE_LABEL}' \
CANARY_PROXY_LABEL='${CANARY_PROXY_LABEL}' \
CANARY_TUNNEL_LABEL='${CANARY_TUNNEL_LABEL}' \
DEEPSEEK_COMPAT_LABEL='${DEEPSEEK_COMPAT_LABEL}' \
EDGE_HOST='${EDGE_HOST}' \
CANARY_PROXY_PORT='${CANARY_PROXY_PORT}' \
CANARY_TUNNEL_REMOTE_PORT='${CANARY_TUNNEL_REMOTE_PORT}' \
DEEPSEEK_COMPAT_PORT='${DEEPSEEK_COMPAT_PORT}' \
EDGE_MOBILE_CONFIG='${EDGE_MOBILE_CONFIG}' \
EDGE_WECHAT_CONFIG='${EDGE_WECHAT_CONFIG}' \
CANARY_USERNAMES='${CANARY_USERNAMES}' \
CANARY_WECHAT_USER_IDS='${CANARY_WECHAT_USER_IDS}' \
/bin/bash" <<'REMOTE_SCRIPT'
set -euo pipefail
LAUNCHD_GUI="gui/$(id -u)"
CANDIDATE_DIR="${CANDIDATE_BASE}/${RELEASE_ID}"
BUNDLE="${INCOMING_DIR}/memind-portal-canary-${RELEASE_ID}.tar.gz"
MANIFEST="${INCOMING_DIR}/memind-portal-canary-${RELEASE_ID}.manifest.txt"
SHA_FILE="${INCOMING_DIR}/memind-portal-canary-${RELEASE_ID}.sha256"
FULL_BACKUP_TAR="${BACKUP_DIR}/memind-full-${RELEASE_ID}-before-canary.tar.gz"
PERSIST_BACKUP_TAR="${BACKUP_DIR}/memind-persisted-${RELEASE_ID}-before-canary.tar.gz"
CANDIDATE_PLIST="${HOME}/Library/LaunchAgents/${PORTAL_CANDIDATE_LABEL}.plist"
PROXY_PLIST="${HOME}/Library/LaunchAgents/${CANARY_PROXY_LABEL}.plist"
CANARY_TUNNEL_PLIST="${HOME}/Library/LaunchAgents/${CANARY_TUNNEL_LABEL}.plist"
DEEPSEEK_COMPAT_PLIST="${HOME}/Library/LaunchAgents/${DEEPSEEK_COMPAT_LABEL}.plist"
SECRET_FILE="${HOME}/.config/memind/canary-router.secret"
DOCKER_BIN="/opt/homebrew/bin/docker"
GOOSED_COMPOSE="${GOOSED_DIR}/docker-compose.prod.yml"
EDGE_MOBILE_BACKUP="${EDGE_MOBILE_CONFIG}.before-canary-${RELEASE_ID}"
EDGE_WECHAT_BACKUP="${EDGE_WECHAT_CONFIG}.before-canary-${RELEASE_ID}"
say() {
printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*"
}
candidate_healthy() {
local headers code
headers="$(mktemp)"
code="$(
curl -sS -D "${headers}" -o /dev/null -w '%{http_code}' \
http://127.0.0.1:18081/api/status 2>/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}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>${CANARY_TUNNEL_LABEL}</string>
<key>ProgramArguments</key>
<array><string>${STABLE_DIR}/scripts/memind-portal-tunnel.sh</string></array>
<key>EnvironmentVariables</key>
<dict>
<key>MEMIND_PORTAL_TUNNEL_HOST</key><string>${EDGE_HOST}</string>
<key>MEMIND_PORTAL_TUNNEL_LOCAL_PORT</key><string>${CANARY_PROXY_PORT}</string>
<key>MEMIND_PORTAL_TUNNEL_REMOTE_PORT</key><string>${CANARY_TUNNEL_REMOTE_PORT}</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>${HOME}/Library/Logs/memind-canary-tunnel.log</string>
<key>StandardErrorPath</key><string>${HOME}/Library/Logs/memind-canary-tunnel.log</string>
</dict>
</plist>
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}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>${DEEPSEEK_COMPAT_LABEL}</string>
<key>ProgramArguments</key>
<array><string>${CANDIDATE_DIR}/scripts/run-deepseek-compat-proxy-candidate.sh</string></array>
<key>EnvironmentVariables</key>
<dict>
<key>MEMIND_CANARY_STABLE_ROOT</key><string>${STABLE_DIR}</string>
<key>MEMIND_CANARY_DEEPSEEK_PROXY_PORT</key><string>${DEEPSEEK_COMPAT_PORT}</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>5</integer>
<key>StandardOutPath</key><string>${HOME}/Library/Logs/memind-deepseek-compat-candidate.log</string>
<key>StandardErrorPath</key><string>${HOME}/Library/Logs/memind-deepseek-compat-candidate.log</string>
</dict>
</plist>
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}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>${PORTAL_CANDIDATE_LABEL}</string>
<key>ProgramArguments</key>
<array><string>${CANDIDATE_DIR}/scripts/run-memind-portal-candidate.sh</string></array>
<key>EnvironmentVariables</key>
<dict>
<key>MEMIND_CANARY_STABLE_ROOT</key><string>${STABLE_DIR}</string>
<key>MEMIND_CANARY_RELEASE_ID</key><string>${RELEASE_ID}</string>
<key>MEMIND_CANARY_CANDIDATE_PORT</key><string>18081</string>
<key>MEMIND_CANARY_GOOSED_URL</key><string>https://127.0.0.1:18015</string>
<key>MEMIND_CANARY_DEEPSEEK_PROXY_PORT</key><string>${DEEPSEEK_COMPAT_PORT}</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>5</integer>
<key>StandardOutPath</key><string>${HOME}/Library/Logs/memind-portal-candidate.log</string>
<key>StandardErrorPath</key><string>${HOME}/Library/Logs/memind-portal-candidate.log</string>
</dict>
</plist>
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}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>${CANARY_PROXY_LABEL}</string>
<key>ProgramArguments</key>
<array><string>${CANDIDATE_DIR}/scripts/run-memind-canary-proxy-prod.sh</string></array>
<key>EnvironmentVariables</key>
<dict>
<key>MEMIND_CANARY_STABLE_ROOT</key><string>${STABLE_DIR}</string>
<key>MEMIND_CANARY_SECRET_FILE</key><string>${SECRET_FILE}</string>
<key>MEMIND_CANARY_PROXY_PORT</key><string>${CANARY_PROXY_PORT}</string>
<key>MEMIND_RELEASE_CANARY_USERNAMES</key><string>${CANARY_USERNAMES}</string>
<key>MEMIND_RELEASE_CANARY_WECHAT_USER_IDS</key><string>${CANARY_WECHAT_USER_IDS}</string>
<key>MEMIND_CANARY_CANDIDATE_HEALTH_URLS</key><string>http://127.0.0.1:${DEEPSEEK_COMPAT_PORT}/health</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>5</integer>
<key>StandardOutPath</key><string>${HOME}/Library/Logs/memind-canary-proxy.log</string>
<key>StandardErrorPath</key><string>${HOME}/Library/Logs/memind-canary-proxy.log</string>
</dict>
</plist>
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 <<EDGE_SWITCH
set -euo pipefail
for config in '${EDGE_MOBILE_CONFIG}' '${EDGE_WECHAT_CONFIG}'; do
before_count="\$(grep -cF 'proxy_pass http://58.38.22.103:8081;' "\${config}")"
[[ "\${before_count}" -gt 0 ]]
temp_config="\$(mktemp "\${config}.canary.XXXXXX")"
sed 's#proxy_pass http://58\.38\.22\.103:8081;#proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};#g' \
"\${config}" > "\${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}"