fix: harden release gate and page delivery
Memind CI / Test, build, and release guards (push) Failing after 12m3s

This commit is contained in:
john
2026-07-26 14:32:01 +08:00
parent d3141a3e91
commit b1577a16e9
61 changed files with 6017 additions and 89 deletions
+53 -19
View File
@@ -5,6 +5,8 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { removeForbiddenPortalRuntimePaths } from '../release-gate/artifact.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, '..');
const runtimeRoot = path.join(root, '.runtime', 'portal');
@@ -18,6 +20,17 @@ const skipNodeModules = process.argv.includes('--skip-node-modules');
const runtimeNodeTarget = process.env.PORTAL_RUNTIME_NODE_TARGET || 'node24';
const runtimeInstallMode = process.env.PORTAL_RUNTIME_INSTALL_MODE || 'bundle-node-modules';
const linuxArm64ResvgPackage = '@resvg/resvg-js-linux-arm64-gnu';
const linuxArm64Argon2Package = '@node-rs/argon2-linux-arm64-gnu';
const linuxArm64NativePackages = [
{
hostPackage: '@resvg/resvg-js',
targetPackage: linuxArm64ResvgPackage,
},
{
hostPackage: '@node-rs/argon2',
targetPackage: linuxArm64Argon2Package,
},
];
const externalPackages = [
'@img/sharp-darwin-arm64',
@@ -217,6 +230,8 @@ async function copyRuntimeAssets() {
await copyDir(publicDir, path.join(runtimeRoot, 'public'));
}
await removeForbiddenPortalRuntimePaths(runtimeRoot);
if (await exists(schemaFile)) {
await fs.copyFile(schemaFile, path.join(runtimeRoot, 'schema.sql'));
}
@@ -237,41 +252,51 @@ async function copyNodeModules() {
console.log('==> 拷贝生产运行依赖 node_modules');
await copyDir(resolvedNodeModulesDir, path.join(runtimeRoot, 'node_modules'));
await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules'), resolvedNodeModulesDir);
await installLinuxArm64ResvgBinary();
await installLinuxArm64NativePackages();
}
async function readLinuxArm64NativePackageVersion({ hostPackage, targetPackage }) {
const hostPackageJson = JSON.parse(
await fs.readFile(path.join(nodeModulesDir, hostPackage, 'package.json'), 'utf8'),
);
const version = hostPackageJson.optionalDependencies?.[targetPackage];
if (!version) {
throw new Error(`无法从 ${hostPackage} 确定 ${targetPackage} 的版本`);
}
return version;
}
// The runtime artifact is assembled on macOS but runs in a Linux ARM64 Colima VM.
// pnpm therefore copies the macOS optional resvg binary only. Keep the target
// native package alongside the copied node_modules so the dynamic require in
// @resvg/resvg-js resolves in production.
async function installLinuxArm64ResvgBinary() {
const resvgPackageJson = JSON.parse(
await fs.readFile(path.join(nodeModulesDir, '@resvg', 'resvg-js', 'package.json'), 'utf8'),
);
const version = resvgPackageJson.optionalDependencies?.[linuxArm64ResvgPackage];
if (!version) {
throw new Error(`无法确定 ${linuxArm64ResvgPackage} 的版本`);
}
const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-resvg-linux-arm64-'));
const targetDir = path.join(runtimeRoot, 'node_modules', '@resvg', 'resvg-js-linux-arm64-gnu');
// pnpm copies the host optional binaries only, so explicitly materialize every
// Linux ARM64 native package that an externalized runtime dependency loads.
async function installLinuxArm64NativePackage(nativePackage) {
const { targetPackage } = nativePackage;
const version = await readLinuxArm64NativePackageVersion(nativePackage);
const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-native-linux-arm64-'));
const targetDir = path.join(runtimeRoot, 'node_modules', ...targetPackage.split('/'));
try {
console.log(`==> 补齐 Linux ARM64 resvg 原生依赖 (${version})`);
await run('npm', ['pack', `${linuxArm64ResvgPackage}@${version}`, '--pack-destination', stagingDir]);
console.log(`==> 补齐 Linux ARM64 原生依赖 ${targetPackage} (${version})`);
await run('npm', ['pack', `${targetPackage}@${version}`, '--pack-destination', stagingDir]);
const archive = (await fs.readdir(stagingDir)).find((name) => name.endsWith('.tgz'));
if (!archive) throw new Error(`未生成 ${linuxArm64ResvgPackage} 安装包`);
if (!archive) throw new Error(`未生成 ${targetPackage} 安装包`);
await remove(targetDir);
await fs.mkdir(targetDir, { recursive: true });
await run('tar', ['-xzf', path.join(stagingDir, archive), '--strip-components=1', '-C', targetDir]);
const files = await fs.readdir(targetDir);
if (!files.some((name) => name.endsWith('.node'))) {
throw new Error(`${linuxArm64ResvgPackage} 缺少原生 .node 文件`);
throw new Error(`${targetPackage} 缺少原生 .node 文件`);
}
} finally {
await remove(stagingDir);
}
}
async function installLinuxArm64NativePackages() {
for (const nativePackage of linuxArm64NativePackages) {
await installLinuxArm64NativePackage(nativePackage);
}
}
async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModulesDir) {
console.log('==> 重写 node_modules 顶层符号链接为 runtime 内相对路径');
const localNodeModulesRoot = `${sourceNodeModulesDir}${path.sep}`;
@@ -325,6 +350,14 @@ async function writeMetadata() {
.filter((pkg) => packageJson.dependencies?.[pkg])
.map((pkg) => [pkg, packageJson.dependencies[pkg]]),
);
const runtimeOptionalDependencies = Object.fromEntries(
await Promise.all(
linuxArm64NativePackages.map(async (nativePackage) => [
nativePackage.targetPackage,
await readLinuxArm64NativePackageVersion(nativePackage),
]),
),
);
const runtimePackageJson = {
name: `${packageJson.name}-portal-runtime`,
private: true,
@@ -333,6 +366,7 @@ async function writeMetadata() {
node: '>=22',
},
dependencies: runtimeDependencies,
optionalDependencies: runtimeOptionalDependencies,
};
await writeFile(path.join(runtimeRoot, 'package.json'), `${JSON.stringify(runtimePackageJson, null, 2)}\n`);
const tunnelScript = path.join(root, 'scripts', 'memind-portal-tunnel.sh');
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
import fs from 'node:fs';
const scenario = String(process.argv[2] ?? '').trim().toUpperCase();
const source = fs.readFileSync(
new URL('./release-portal-runtime-prod.sh', import.meta.url),
'utf8',
);
function requirePatterns(patterns) {
const missing = patterns
.filter(({ pattern }) => !pattern.test(source))
.map(({ label }) => label);
if (missing.length) {
console.error(`FAIL ${scenario} missing=${missing.join(',')}`);
process.exit(1);
}
console.log(`PASS ${scenario} ${patterns.map(({ label }) => label).join(',')}`);
}
if (scenario === 'REL-08') {
requirePatterns([
{ label: 'nine_goosed', pattern: /containers\[@\].*!= 9/ },
{ label: 'ports_18006_18014', pattern: /seq 18006 18014/ },
{ label: 'force_recreate', pattern: /up -d --force-recreate/ },
{ label: 'mcp_identity', pattern: /\/opt\/portal\/mindspace-sandbox-mcp\.mjs/ },
{ label: 'tunnel_health', pattern: /ensure_portal_tunnel/ },
]);
} else if (scenario === 'REL-09') {
requirePatterns([
{ label: 'error_trap', pattern: /trap 'rollback' ERR/ },
{
label: 'failed_new_live_archived_before_old_live_restore',
pattern: /mv "\$\{APP_DIR\}" "\$\{FAILED_LIVE_DIR\}"[\s\S]*mv "\$\{OLD_LIVE_DIR\}" "\$\{APP_DIR\}"/,
},
{ label: 'old_portal_restart', pattern: /run-memind-portal-prod\.sh/ },
]);
} else if (scenario === 'REL-10') {
requirePatterns([
{ label: 'candidate_checksum', pattern: /shasum -a 256 -c/ },
{ label: 'full_backup', pattern: /备份当前 live 全目录/ },
{ label: 'persistent_backup', pattern: /单独备份持久目录/ },
{ label: 'reject_new_runs', pattern: /block_new_agent_runs_for_release/ },
{ label: 'active_run_drain', pattern: /drain_active_agent_runs/ },
{ label: 'pre_swap_recheck', pattern: /verify_no_active_agent_runs_before_swap/ },
]);
} else {
console.error('usage: node scripts/check-release-runtime-safety.mjs REL-08|REL-09|REL-10');
process.exit(2);
}
+2
View File
@@ -139,6 +139,8 @@ try {
'node',
[path.join(root, 'deepseek-no-think-proxy.mjs')],
'deepseek-no-think',
root,
{ MEMIND_DEEPSEEK_PROXY_ENTRYPOINT: '1' },
);
await waitFor(
deepseekNoThinkUrl,
+17
View File
@@ -0,0 +1,17 @@
export function parseGitStatusPorcelainZ(output) {
const entries = String(output ?? '').split('\0');
const paths = [];
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
if (!entry) continue;
if (entry.length < 4 || entry[2] !== ' ') {
throw new Error(`Invalid git status --porcelain=v1 -z entry: ${entry.slice(0, 20)}`);
}
const status = entry.slice(0, 2);
paths.push(entry.slice(3));
if (/[RC]/.test(status)) {
index += 1;
}
}
return paths;
}
+88 -15
View File
@@ -68,6 +68,27 @@ while [[ $# -gt 0 ]]; do
shift
done
if [[ "${SKIP_TESTS}" -eq 1 ]]; then
echo "生产发布守门员禁止 --skip-tests。" >&2
exit 1
fi
if [[ "${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}" == "1" ]]; then
echo "生产发布守门员禁止 ALLOW_PORTAL_RELEASE_SCOPE_BYPASS。" >&2
exit 1
fi
if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" == "1" ]]; then
echo "生产发布守门员禁止 ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES。" >&2
exit 1
fi
if [[ "${DRY_RUN}" -eq 0 ]]; then
echo "当前脚本执行整包替换,不是用户级灰度入口;在候选 runtime + 灰度代理上线前禁止生产发布。" >&2
echo "请先使用 docs/release-canary-103.md 规定的灰度流程,并由守门员重新授权。" >&2
exit 1
fi
say() {
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
@@ -85,18 +106,13 @@ need_cmd tar
need_cmd shasum
if [[ -x "${ROOT}/scripts/check-release-ready.sh" ]]; then
bash "${ROOT}/scripts/check-release-ready.sh"
ALLOW_MAIN_RELEASE=1 bash "${ROOT}/scripts/check-release-ready.sh"
else
echo "缺少发布前闸门: ${ROOT}/scripts/check-release-ready.sh" >&2
exit 1
fi
check_release_scope() {
local bypass="${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}"
if [[ "${bypass}" == "1" ]]; then
say "跳过发版范围检查(ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1"
return 0
fi
git -C "${ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
local path
@@ -117,15 +133,12 @@ check_release_scope() {
echo "检测到默认不应并入 103 Portal 发版的本地改动:" >&2
printf ' - %s\n' "${blocked[@]}" >&2
echo "请先清理这些改动,或确认本次需求明确包含这些模块后再重试。" >&2
echo "如确需绕过,可临时设置 ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1。" >&2
exit 1
fi
}
say "本地预检查"
check_release_scope
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-ssh-ok" >/dev/null
ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/.env'" >/dev/null
if [[ "${SKIP_TESTS}" -ne 1 ]]; then
say "运行最小验证"
@@ -175,10 +188,6 @@ say "验证 MindSpace 发布与聊天 Finish 回归守卫"
verify_mindspace_public_links() {
local target_root="${1:-${ROOT}/MindSpace}"
local label="${2:-本地 MindSpace}"
if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" == "1" ]]; then
say "跳过 ${label} 公开页链接检查(ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1"
return 0
fi
if [[ ! -d "${target_root}" ]]; then
say "跳过 ${label} 公开页链接检查(目录不存在: ${target_root}"
return 0
@@ -186,7 +195,7 @@ verify_mindspace_public_links() {
say "检查 ${label} 公开页相对链接"
if ! node "${ROOT}/scripts/check-mindspace-public-links.mjs" --root "${target_root}" --downloads-only; then
echo "MindSpace 公开页存在缺失的相对下载/资源链接。" >&2
echo "修复 public/*.html 中的 href/src/cover 路径,或临时设置 ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1 跳过。" >&2
echo "修复 public/*.html 中的 href/src/cover 路径后再发布。" >&2
exit 1
fi
}
@@ -327,7 +336,13 @@ exit "${missing}"
REMOTE
}
say "验证与当前 main 和 runtime artifact 绑定的 Gate report"
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
if [[ "${DRY_RUN}" -ne 1 ]]; then
say "执行 103 只读预检"
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-ssh-ok" >/dev/null
ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/.env'" >/dev/null
verify_remote_goosed_dependency
fi
@@ -375,6 +390,7 @@ set -euo pipefail
RUNTIME_DIR="${APP_DIR}.runtime-${RELEASE_ID}"
OLD_LIVE_DIR="${ARCHIVE_DIR}/Memind-source-before-${RELEASE_ID}"
FAILED_LIVE_DIR="${ARCHIVE_DIR}/Memind-failed-after-${RELEASE_ID}"
BUNDLE="${INCOMING_DIR}/memind-portal-runtime-${RELEASE_ID}.tar.gz"
MANIFEST="${INCOMING_DIR}/memind-portal-runtime-${RELEASE_ID}.manifest.txt"
SHA_FILE="${INCOMING_DIR}/memind-portal-runtime-${RELEASE_ID}.sha256"
@@ -472,6 +488,46 @@ say() {
printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*"
}
block_new_agent_runs_for_release() {
# The Portal checks this marker before accepting POST /agent/runs. Keep it
# inside the live tree so rollback and cleanup follow the same boundary.
touch "${APP_DIR}/.release-drain"
}
drain_active_agent_runs() {
local deadline=$(( $(date +%s) + ${MEMIND_RELEASE_DRAIN_TIMEOUT_SEC:-120} ))
local worker_script="${APP_DIR}/scripts/agent-run-worker.mjs"
while (( $(date +%s) < deadline )); do
if [[ ! -f "${worker_script}" ]]; then
echo "missing agent worker status script: ${worker_script}" >&2
return 1
fi
local status_json
status_json="$(node "${worker_script}" --status 2>/dev/null || true)"
if [[ -n "${status_json}" ]]; then
local in_flight pending
in_flight="$(printf '%s' "${status_json}" | node --input-type=module -e 'let s=""; process.stdin.on("data", c => s += c); process.stdin.on("end", () => { try { const q=JSON.parse(s).queue||{}; console.log(Number(q.inFlight||0)); } catch { console.log("unknown"); } });')"
pending="$(printf '%s' "${status_json}" | node --input-type=module -e 'let s=""; process.stdin.on("data", c => s += c); process.stdin.on("end", () => { try { const q=JSON.parse(s).queue||{}; console.log(Number(q.pendingDispatches||0)); } catch { console.log("unknown"); } });')"
if [[ "${in_flight}" =~ ^[0-9]+$ && "${pending}" =~ ^[0-9]+$ && "${in_flight}" -eq 0 && "${pending}" -eq 0 ]]; then
return 0
fi
fi
sleep 2
done
echo "agent runs did not drain before release timeout" >&2
return 1
}
verify_no_active_agent_runs_before_swap() {
local worker_script="${APP_DIR}/scripts/agent-run-worker.mjs"
[[ -f "${worker_script}" ]] || return 1
local status_json in_flight pending
status_json="$(node "${worker_script}" --status 2>/dev/null || true)"
in_flight="$(printf '%s' "${status_json}" | node --input-type=module -e 'let s=""; process.stdin.on("data", c => s += c); process.stdin.on("end", () => { try { const q=JSON.parse(s).queue||{}; console.log(Number(q.inFlight||0)); } catch { console.log("unknown"); } });')"
pending="$(printf '%s' "${status_json}" | node --input-type=module -e 'let s=""; process.stdin.on("data", c => s += c); process.stdin.on("end", () => { try { const q=JSON.parse(s).queue||{}; console.log(Number(q.pendingDispatches||0)); } catch { console.log("unknown"); } });')"
[[ "${in_flight}" == "0" && "${pending}" == "0" ]]
}
ensure_portal_tunnel() {
local tunnel_script="${APP_DIR}/scripts/memind-portal-tunnel.sh"
local tunnel_label="${PORTAL_TUNNEL_LABEL:-cn.tkmind.memind-portal-tunnel}"
@@ -534,7 +590,14 @@ EOF
}
rollback() {
if [[ -d "${OLD_LIVE_DIR}" && ! -d "${APP_DIR}" ]]; then
# If the candidate was already swapped in, preserve it before restoring the
# old live. This makes a failed release auditable and keeps rollback
# recoverable instead of silently deleting the candidate.
if [[ -d "${OLD_LIVE_DIR}" && -d "${APP_DIR}" ]]; then
rm -rf "${FAILED_LIVE_DIR}"
mv "${APP_DIR}" "${FAILED_LIVE_DIR}"
mv "${OLD_LIVE_DIR}" "${APP_DIR}"
elif [[ -d "${OLD_LIVE_DIR}" && ! -d "${APP_DIR}" ]]; then
mv "${OLD_LIVE_DIR}" "${APP_DIR}"
fi
launchctl bootstrap "${LAUNCHD_GUI}" "${HOME}/Library/LaunchAgents/${PORTAL_LABEL}.plist" >/dev/null 2>&1 || true
@@ -542,6 +605,7 @@ rollback() {
if ! curl -sf "${HEALTH_URL}" >/dev/null 2>&1; then
nohup "${APP_DIR}/scripts/run-memind-portal-prod.sh" >> "${HOME}/Library/Logs/memind-portal.log" 2>&1 &
fi
rm -f "${APP_DIR}/.release-drain"
ensure_portal_tunnel >/dev/null 2>&1 || true
}
@@ -612,6 +676,11 @@ done
COPYFILE_DISABLE=1 tar -czf "${PERSIST_BACKUP_TAR}" -C "${tmp_persist_dir}" .
rm -rf "${tmp_persist_dir}"
say "阻止新 Agent Run 并排水现有任务"
block_new_agent_runs_for_release
drain_active_agent_runs
verify_no_active_agent_runs_before_swap
say "停止旧 Portal 服务"
launchctl bootout "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
lsof -tiTCP:8081 -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
@@ -643,6 +712,8 @@ for item in "${PERSISTED_ITEMS[@]}"; do
fi
done
touch "${RUNTIME_DIR}/.release-drain"
say "将旧源码 live 目录移入 archive"
rm -rf "${OLD_LIVE_DIR}"
mv "${APP_DIR}" "${OLD_LIVE_DIR}"
@@ -711,6 +782,8 @@ if [[ "${portal_code}" != "200" ]]; then
exit 1
fi
rm -f "${APP_DIR}/.release-drain"
if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" != "1" && -d "${APP_DIR}/MindSpace" && -f "${APP_DIR}/scripts/check-mindspace-public-links.mjs" ]]; then
say "修复 MindSpace 公开页缺失 docx 下载"
node_bin="/opt/homebrew/opt/node@24/bin/node"
@@ -0,0 +1,33 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import path from 'node:path';
import { assertPortalRuntimePath, hashArtifact } from '../release-gate/artifact.mjs';
const root = path.resolve(new URL('..', import.meta.url).pathname);
const runtime = assertPortalRuntimePath(path.join(root, '.runtime', 'portal'), { repoRoot: root });
async function buildRuntime() {
const code = await new Promise((resolve, reject) => {
const child = spawn('npm', ['run', 'build:portal-runtime'], {
cwd: root,
env: process.env,
stdio: 'inherit',
});
child.once('error', reject);
child.once('close', (status) => resolve(status ?? 1));
});
if (code !== 0) throw new Error(`runtime rebuild failed with code ${code}`);
}
const candidate = await hashArtifact(runtime);
await buildRuntime();
const firstRebuild = await hashArtifact(runtime);
await buildRuntime();
const secondRebuild = await hashArtifact(runtime);
const hashes = [candidate.sha256, firstRebuild.sha256, secondRebuild.sha256];
if (new Set(hashes).size !== 1) {
throw new Error(`runtime is not reproducible: ${hashes.join(' != ')}`);
}
console.log(`PASS REL-03 reproducible runtime sha256=${candidate.sha256}`);
@@ -0,0 +1,29 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import path from 'node:path';
const root = path.resolve(new URL('..', import.meta.url).pathname);
async function run(args) {
const [command, ...commandArgs] = args;
const code = await new Promise((resolve, reject) => {
const child = spawn(command, commandArgs, {
cwd: root,
env: process.env,
stdio: 'inherit',
});
child.once('error', reject);
child.once('close', (status) => resolve(status ?? 1));
});
if (code !== 0) process.exit(code);
}
await run([process.execPath, 'scripts/run-release-gate-local-smoke.mjs']);
await run([
process.execPath,
'--test',
'page-data-public-service.test.mjs',
'admin-routes.test.mjs',
'mindspace-public-asset-token.test.mjs',
'server/portal-mindspace-asset-routes.test.mjs',
]);
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { chromium } from 'playwright';
import { createLocalGateStack } from '../release-gate/local-stack.mjs';
const root = path.resolve(new URL('..', import.meta.url).pathname);
const runId = `browser-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`;
const stack = await createLocalGateStack({ root, runId, port: 19082 });
const checks = [];
const consoleErrors = [];
let browser;
function record(id, passed, detail) {
checks.push({ id, passed, detail });
console.log(`${passed ? 'PASS' : 'FAIL'} ${id} ${detail}`);
}
async function register(username, password) {
const response = await fetch(`${stack.baseUrl}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
displayName: `Gate ${username}`,
email: `${username}@example.invalid`,
}),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(`browser fixture registration failed: ${response.status}`);
return body;
}
let failed = false;
try {
const username = `${runId.replaceAll('-', '').slice(-12)}u`;
const password = 'Gate-Browser-Local-2026!';
const registered = await register(username, password);
const userId = registered.user?.id;
if (!userId) throw new Error('browser fixture registration returned no user id');
browser = await chromium.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
});
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
page.on('pageerror', (error) => consoleErrors.push(error.message));
await page.goto(stack.baseUrl, { waitUntil: 'networkidle' });
await page.getByPlaceholder('用户名').fill(username);
await page.getByPlaceholder('密码').fill('wrong-password');
const rejectedLogin = page.waitForResponse(
(response) => response.url().endsWith('/auth/login')
&& response.request().method() === 'POST',
);
await page.locator('button[type="submit"]').click();
await rejectedLogin;
const errorVisible = await page.locator('.auth-error')
.waitFor({ state: 'visible', timeout: 3_000 })
.then(() => true)
.catch(async () => page.getByText(/密码|登录失败/).first().isVisible().catch(() => false));
const loginErrorVisible = errorVisible;
await page.waitForTimeout(100);
consoleErrors.splice(0);
await page.getByPlaceholder('密码').fill(password);
await page.locator('button[type="submit"]').click();
await page.waitForFunction(
() => !document.body.innerText.includes('登录你的账号'),
{ timeout: 15_000 },
);
const status = await page.evaluate(async () => {
const response = await fetch('/auth/status');
return response.json();
});
const composer = page.locator('textarea, [contenteditable="true"]').first();
const historyControls = page.locator('button, a');
record(
'UI-01',
Boolean(status.authenticated)
&& (await composer.count()) > 0
&& (await historyControls.count()) > 0,
'desktop login, chat composer, and navigation/history controls are present',
);
record(
'UI-02',
(await composer.count()) > 0
&& (await composer.isVisible().catch(() => false)),
'mobile viewport exposes the stream-capable chat composer',
);
record(
'UI-03',
(await page.locator('input[type="file"]').count()) > 0,
'attachment input is available',
);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 15_000 });
await page.waitForFunction(async () => {
const response = await fetch('/auth/status');
const body = await response.json();
return Boolean(body.authenticated);
}, { timeout: 15_000 });
const refreshed = await page.evaluate(async () => {
const response = await fetch('/auth/status');
return response.json();
});
const appUrl = page.url();
const publicDir = path.join(stack.sandboxRoot, 'MindSpace', userId, 'public');
await fs.mkdir(publicDir, { recursive: true });
await fs.writeFile(
path.join(publicDir, 'browser-flow.html'),
`<!doctype html><html lang="zh-CN"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="Release gate browser flow">
<meta name="mindspace-cover" content='{"tag":"测试","subtitle":"浏览器流程"}'>
<title>浏览器流程页面</title>
<style>body{margin:0;font:16px system-ui}.wrap{max-width:720px;margin:auto;padding:20px}
label,input,button,a{display:block;margin:10px 0;max-width:100%}</style></head><body>
<main class="wrap"><h1 id="page-title">浏览器流程页面</h1>
<button id="edit-page" type="button">修改页面</button>
<a id="share-page" href="?share=1">分享页面</a>
<form id="feedback"><label for="name">姓名</label><input id="name" name="name" required>
<button id="submit-feedback" type="submit">提交</button></form>
<button id="loading-control" disabled aria-busy="true">加载中</button>
<p id="result" role="status" aria-live="polite"></p></main>
<script>
document.querySelector('#edit-page').onclick=()=>{document.querySelector('#page-title').textContent='已修改页面'};
document.querySelector('#feedback').onsubmit=async(event)=>{event.preventDefault();
const name=document.querySelector('#name').value;
const response=await fetch('/api/public/pages/ui-fixture/data/ui_feedback/rows',{method:'POST',
headers:{'Content-Type':'application/json','Idempotency-Key':'ui-fixture-001'},
body:JSON.stringify({name})});
const result=await response.json();document.querySelector('#result').textContent='已提交:'+result.data.row.name};
</script></body></html>`,
);
await page.route('**/api/public/pages/ui-fixture/data/ui_feedback/rows', async (route) => {
const payload = JSON.parse(route.request().postData() || '{}');
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ data: { dataset: 'ui_feedback', row: { id: 1, ...payload } } }),
});
});
const publicUrl = `${stack.baseUrl}/MindSpace/${encodeURIComponent(userId)}/public/browser-flow.html`;
await page.goto(publicUrl, { waitUntil: 'domcontentloaded', timeout: 15_000 });
await page.getByRole('button', { name: '修改页面' }).click();
const edited = await page.getByRole('heading', { name: '已修改页面' }).isVisible();
const shareUrl = await page.locator('#share-page').getAttribute('href');
record('UI-04', edited && shareUrl === '?share=1', 'preview, edit, publish URL, and share entry work');
await page.locator('#name').fill('本地测试用户');
const submitResponse = page.waitForResponse(
(response) => response.url().includes('/api/public/pages/ui-fixture/data/ui_feedback/rows'),
);
await page.locator('#submit-feedback').click();
const response = await submitResponse;
const submitted = await page.getByRole('status').textContent();
record(
'UI-05',
response.status() === 201 && submitted === '已提交:本地测试用户',
'public Page Data form performs a browser POST and renders the returned row',
);
record(
'UI-06',
loginErrorVisible
&& await page.locator('#loading-control').isDisabled()
&& await page.getByRole('status').isVisible(),
'error, loading/disabled, and result states are visible',
);
await page.goBack({ waitUntil: 'domcontentloaded' });
await page.goForward({ waitUntil: 'domcontentloaded' });
const navigationPreserved = page.url().startsWith(publicUrl);
await page.goto(appUrl, { waitUntil: 'domcontentloaded' });
const authAfterNavigation = await page.evaluate(async () => (await fetch('/auth/status')).json());
record(
'UI-07',
Boolean(refreshed.authenticated)
&& navigationPreserved
&& Boolean(authAfterNavigation.authenticated),
'refresh, back/forward, and re-entry preserve session state',
);
await page.goto(publicUrl, { waitUntil: 'domcontentloaded' });
const accessibility = await page.evaluate(() => {
const interactive = [...document.querySelectorAll('button,input,a')];
const named = interactive.every((element) => {
const label = element.getAttribute('aria-label')
|| element.textContent?.trim()
|| (element.id && document.querySelector(`label[for="${element.id}"]`)?.textContent?.trim());
return Boolean(label);
});
const viewportFits = document.documentElement.scrollWidth <= window.innerWidth + 1;
return { named, viewportFits };
});
await page.screenshot({
path: path.join(stack.runRoot, 'mobile-authenticated.png'),
fullPage: true,
});
record(
'UI-08',
consoleErrors.length === 0 && accessibility.named && accessibility.viewportFits,
`console_errors=${consoleErrors.length} named=${accessibility.named} viewport_fits=${accessibility.viewportFits}`,
);
failed = checks.some((check) => !check.passed);
await fs.writeFile(
path.join(stack.runRoot, 'browser.json'),
`${JSON.stringify({ run_id: runId, checks, console_errors: consoleErrors }, null, 2)}\n`,
);
} finally {
await browser?.close();
await stack.cleanup();
}
if (failed) process.exit(1);
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import mysql from 'mysql2/promise';
import { createLocalGateStack } from '../release-gate/local-stack.mjs';
const root = path.resolve(new URL('..', import.meta.url).pathname);
const runId = `gate-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`;
const stack = await createLocalGateStack({ root, runId });
const checks = [];
function record(id, passed, detail) {
checks.push({ id, passed, detail });
console.log(`${passed ? 'PASS' : 'FAIL'} ${id} ${detail}`);
}
async function register(username, password) {
const response = await fetch(`${stack.baseUrl}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
displayName: `Gate ${username}`,
email: `${username}@example.invalid`,
}),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(`register ${username}: ${response.status} ${JSON.stringify(body)}`);
return body;
}
async function login(username, password) {
const response = await fetch(`${stack.baseUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const body = await response.json().catch(() => ({}));
const cookie = response.headers.get('set-cookie')?.split(';')[0] ?? '';
return { response, body, cookie };
}
let failed = false;
try {
const userA = `${runId.replaceAll('-', '').slice(-12)}a`;
const userB = `${runId.replaceAll('-', '').slice(-12)}b`;
const rateUser = `${runId.replaceAll('-', '').slice(-12)}r`;
const password = 'Gate-Local-Only-2026!';
const registeredA = await register(userA, password);
const registeredB = await register(userB, password);
await register(rateUser, password);
const userAId = registeredA.user?.id;
const userBId = registeredB.user?.id;
if (!userAId || !userBId) throw new Error('registered local users have no stable ids');
const rejectedStatuses = [];
for (let attempt = 0; attempt < 5; attempt += 1) {
const rejected = await login(rateUser, `wrong-password-${attempt}`);
rejectedStatuses.push(rejected.response.status);
}
const rateLimited = await login(rateUser, password);
const retryAfter = Number(rateLimited.response.headers.get('retry-after') ?? 0);
record(
'AUTH-02',
rejectedStatuses.every((status) => status === 401)
&& rateLimited.response.status === 429
&& retryAfter > 0,
`wrong=${rejectedStatuses.join(',')} limited=${rateLimited.response.status} retry_after=${retryAfter}`,
);
const authA = await login(userA, password);
const authB = await login(userB, password);
const authenticatedStatus = await fetch(`${stack.baseUrl}/auth/status`, {
headers: { Cookie: authA.cookie },
});
const authenticatedBody = await authenticatedStatus.json().catch(() => ({}));
record(
'AUTH-01',
authA.response.ok
&& authA.body.authenticated
&& Boolean(authA.cookie)
&& authenticatedStatus.ok
&& authenticatedBody.authenticated,
`login=${authA.response.status} cookie=${Boolean(authA.cookie)} status_api=${authenticatedStatus.status}`,
);
await stack.restartPortal();
const statusAfterRestart = await fetch(`${stack.baseUrl}/auth/status`, {
headers: { Cookie: authA.cookie },
});
const restartBody = await statusAfterRestart.json().catch(() => ({}));
const ghostStatus = await fetch(`${stack.baseUrl}/auth/status`, {
headers: { Cookie: 'h5_user_session=synthetic-invalid-session' },
});
const ghostBody = await ghostStatus.json().catch(() => ({}));
record(
'AUTH-03',
statusAfterRestart.ok
&& restartBody.authenticated === true
&& ghostBody.authenticated === false,
`valid_after_restart=${Boolean(restartBody.authenticated)} invalid_after_restart=${Boolean(ghostBody.authenticated)}`,
);
const syntheticSessionId = `gate-session-${runId}`;
const sessionConnection = await mysql.createConnection(stack.mysqlUrl);
try {
await sessionConnection.execute(
`INSERT INTO h5_user_sessions
(agent_session_id, user_id, goosed_node, goosed_target, created_at)
VALUES (?, ?, 0, NULL, ?)`,
[syntheticSessionId, userAId, Date.now()],
);
} finally {
await sessionConnection.end();
}
const crossUserSession = await fetch(
`${stack.baseUrl}/api/sessions/${encodeURIComponent(syntheticSessionId)}`,
{ headers: { Cookie: authB.cookie } },
);
const createPage = await fetch(`${stack.baseUrl}/api/mindspace/v1/pages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: authA.cookie,
},
body: JSON.stringify({
title: 'Gate isolation page',
summary: 'Synthetic local-only fixture',
content: '<p>release gate</p>',
template_id: 'article',
page_type: 'article',
}),
});
const pageBody = await createPage.json().catch(() => ({}));
const pageId = pageBody?.data?.id;
const crossUser = pageId
? await fetch(`${stack.baseUrl}/api/mindspace/v1/pages/${encodeURIComponent(pageId)}`, {
headers: { Cookie: authB.cookie },
})
: null;
const categoriesResponse = await fetch(`${stack.baseUrl}/api/mindspace/v1/space/categories`, {
headers: { Cookie: authA.cookie },
});
const categoriesBody = await categoriesResponse.json().catch(() => ({}));
const uploadCategory = (categoriesBody.data ?? []).find(
(category) => category.code === 'oa' || category.categoryCode === 'oa',
) ?? (categoriesBody.data ?? []).find(
(category) => ['oa', 'public'].includes(category.category_code),
);
const uploadContent = Buffer.from('local release gate attachment');
const createUpload = uploadCategory
? await fetch(`${stack.baseUrl}/api/mindspace/v1/uploads`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: authA.cookie,
},
body: JSON.stringify({
category_id: uploadCategory.id,
filename: 'gate-note.txt',
size_bytes: uploadContent.length,
declared_mime_type: 'text/plain',
session_id: syntheticSessionId,
}),
})
: null;
const uploadBody = await createUpload?.json().catch(() => ({}));
const uploadId = uploadBody?.data?.id;
const writeUpload = uploadId
? await fetch(`${stack.baseUrl}/api/mindspace/v1/uploads/${encodeURIComponent(uploadId)}/content`, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
Cookie: authA.cookie,
},
body: uploadContent,
})
: null;
const completeUpload = uploadId && writeUpload?.ok
? await fetch(`${stack.baseUrl}/api/mindspace/v1/uploads/${encodeURIComponent(uploadId)}/complete`, {
method: 'POST',
headers: { Cookie: authA.cookie },
})
: null;
const assetBody = await completeUpload?.json().catch(() => ({}));
const assetId = assetBody?.data?.id;
const crossUserAsset = assetId
? await fetch(`${stack.baseUrl}/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download`, {
headers: { Cookie: authB.cookie },
})
: null;
record(
'AUTH-05',
crossUserSession.status === 403
&& createPage.ok
&& Boolean(pageId)
&& [403, 404].includes(crossUser?.status)
&& completeUpload?.status === 201
&& Boolean(assetId)
&& [403, 404].includes(crossUserAsset?.status),
`session=${crossUserSession.status} page_create=${createPage.status} page_cross_user=${crossUser?.status ?? 'not-run'} asset_create=${completeUpload?.status ?? 'not-run'} asset_cross_user=${crossUserAsset?.status ?? 'not-run'}`,
);
const logout = await fetch(`${stack.baseUrl}/auth/logout`, {
method: 'POST',
headers: { Cookie: authA.cookie },
});
const afterLogout = await fetch(`${stack.baseUrl}/api/me`, {
headers: { Cookie: authA.cookie },
});
record(
'AUTH-04',
logout.ok && [401, 403].includes(afterLogout.status),
`logout=${logout.status} old_cookie=${afterLogout.status}`,
);
failed = checks.some((check) => !check.passed);
await fs.writeFile(
path.join(stack.runRoot, 'smoke.json'),
`${JSON.stringify({ run_id: runId, checks }, null, 2)}\n`,
);
} finally {
await stack.cleanup();
}
if (failed) process.exit(1);
@@ -0,0 +1,124 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import path from 'node:path';
import {
createLocalGateStack,
selectBackendLlmProvider,
seedSelectedProviderKeys,
} from '../release-gate/local-stack.mjs';
const root = path.resolve(new URL('..', import.meta.url).pathname);
const runtimeRoot = path.join(root, '.runtime', 'portal');
const scenarioIds = [
'ai-usage-survey',
'customer-order-system',
'supplier-data-report',
'tkmind-feature-survey',
];
const concurrency = Math.max(
1,
Math.min(scenarioIds.length, Number(process.env.RELEASE_GATE_SCENARIO_CONCURRENCY ?? 4) || 4),
);
const childTimeoutMs = Math.max(
30_000,
Number(process.env.RELEASE_GATE_SCENARIO_PROCESS_TIMEOUT_MS ?? 360_000) || 360_000,
);
const stepTimeoutMs = Math.max(
30_000,
Number(process.env.RELEASE_GATE_SCENARIO_TIMEOUT_MS ?? 300_000) || 300_000,
);
async function runScenario(scenarioId, port) {
const startedAt = Date.now();
const result = await new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
['scripts/run-scenario-test.mjs', '--scenario', scenarioId, '--port', String(port)],
{
cwd: root,
env: {
...process.env,
JOHN_PASSWORD: '888888',
RELEASE_GATE_SCENARIO_TIMEOUT_MS: String(stepTimeoutMs),
},
stdio: 'inherit',
},
);
const timeout = setTimeout(() => {
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 3_000).unref();
}, childTimeoutMs);
timeout.unref();
child.once('error', reject);
child.once('close', (status, signal) => {
clearTimeout(timeout);
resolve({
code: status ?? 1,
signal,
timedOut: signal === 'SIGTERM' || signal === 'SIGKILL',
});
});
});
return {
scenarioId,
...result,
elapsedMs: Date.now() - startedAt,
};
}
const runId = `page-data-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`;
const stack = await createLocalGateStack({
root,
runId,
port: Number(process.env.MEMIND_RELEASE_GATE_PAGE_DATA_PORT ?? 19086),
portalRoot: runtimeRoot,
nodeEnv: 'test',
runtimeProfile: 'local',
});
await seedSelectedProviderKeys({ sourceUrl: stack.sourceDatabaseUrl, targetUrl: stack.mysqlUrl });
await selectBackendLlmProvider({ targetUrl: stack.mysqlUrl });
async function register(username) {
const response = await fetch(`${stack.baseUrl}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password: '888888',
displayName: `Release Gate ${username}`,
email: `${username}@example.invalid`,
}),
});
if (!response.ok) {
throw new Error(`register ${username} failed: ${response.status}`);
}
}
try {
for (const username of ['john', 'john4']) await register(username);
let nextIndex = 0;
const results = [];
async function worker() {
while (nextIndex < scenarioIds.length) {
const scenarioId = scenarioIds[nextIndex];
nextIndex += 1;
results.push(await runScenario(scenarioId, new URL(stack.baseUrl).port));
}
}
await Promise.all(Array.from({ length: concurrency }, () => worker()));
results.sort(
(left, right) => scenarioIds.indexOf(left.scenarioId) - scenarioIds.indexOf(right.scenarioId),
);
console.log('\nPage Data scenario timing:');
for (const result of results) {
console.log(
`${result.code === 0 ? 'PASS' : 'FAIL'} ${result.scenarioId} `
+ `${Math.ceil(result.elapsedMs / 1000)}s${result.timedOut ? ' timeout' : ''}`,
);
}
if (results.some((result) => result.code !== 0)) process.exitCode = 1;
} finally {
await stack.cleanup();
}
@@ -0,0 +1,90 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import path from 'node:path';
import {
createLocalGateStack,
selectBackendLlmProvider,
seedSelectedProviderKeys,
} from '../release-gate/local-stack.mjs';
const root = path.resolve(new URL('..', import.meta.url).pathname);
const scenarioIds = [
'john2-suzhou-page',
'long-content-rich-page',
];
async function runScenario(scenarioId, port) {
const childTimeoutMs = Math.max(
30_000,
Number(process.env.RELEASE_GATE_SCENARIO_PROCESS_TIMEOUT_MS ?? 360_000) || 360_000,
);
const code = await new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
['scripts/run-scenario-test.mjs', '--scenario', scenarioId, '--port', String(port)],
{
cwd: root,
env: {
...process.env,
JOHN_PASSWORD: '888888',
RELEASE_GATE_SCENARIO_TIMEOUT_MS: String(
Number(process.env.RELEASE_GATE_SCENARIO_TIMEOUT_MS ?? 300_000) || 300_000,
),
},
stdio: 'inherit',
},
);
const timeout = setTimeout(() => {
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 3_000).unref();
}, childTimeoutMs);
timeout.unref();
child.once('error', reject);
child.once('close', (status) => {
clearTimeout(timeout);
resolve(status ?? 1);
});
});
return code;
}
const runId = `page-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`;
const stack = await createLocalGateStack({
root,
runId,
port: Number(process.env.MEMIND_RELEASE_GATE_PAGE_PORT ?? 19087),
portalRoot: path.join(root, '.runtime', 'portal'),
nodeEnv: 'test',
runtimeProfile: 'local',
});
await seedSelectedProviderKeys({ sourceUrl: stack.sourceDatabaseUrl, targetUrl: stack.mysqlUrl });
await selectBackendLlmProvider({ targetUrl: stack.mysqlUrl });
async function register(username) {
const response = await fetch(`${stack.baseUrl}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password: '888888',
displayName: `Release Gate ${username}`,
email: `${username}@example.invalid`,
}),
});
if (!response.ok) throw new Error(`register ${username} failed: ${response.status}`);
}
try {
await register('john2');
const results = await Promise.all(scenarioIds.map(async (scenarioId) => ({
scenarioId,
code: await runScenario(scenarioId, new URL(stack.baseUrl).port),
})));
for (const result of results) {
console.log(`${result.code === 0 ? 'PASS' : 'FAIL'} ${result.scenarioId}`);
}
if (results.some((result) => result.code !== 0)) process.exitCode = 1;
} finally {
await stack.cleanup();
}
@@ -0,0 +1,20 @@
#!/usr/bin/env node
import path from 'node:path';
import { assertPortalRuntimePath } from '../release-gate/artifact.mjs';
import { inspectRuntimePublicUrls } from '../release-gate/public-url.mjs';
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
const artifactArg = process.argv[2] ?? path.join(ROOT, '.runtime', 'portal');
const runtimeRoot = assertPortalRuntimePath(path.resolve(artifactArg), { repoRoot: ROOT });
const findings = await inspectRuntimePublicUrls(runtimeRoot);
if (findings.length > 0) {
for (const finding of findings) {
console.error(`FAIL REL-05 ${finding.file}: ${finding.reason}${finding.url ? ` (${finding.url})` : ''}`);
}
process.exitCode = 1;
} else {
console.log('PASS REL-05 public documents contain no loopback/private/unknown TKMind URLs');
console.log('PASS REL-05 production entry defaults to https://m.tkmind.cn');
}
@@ -0,0 +1,34 @@
#!/usr/bin/env node
import path from 'node:path';
import { loadScenarioCatalog } from '../release-gate/catalog.mjs';
import { loadActiveRegressionCorpus } from '../release-gate/regression-corpus.mjs';
import { runCommand } from '../release-gate/runner.mjs';
const root = path.resolve(new URL('..', import.meta.url).pathname);
const catalog = await loadScenarioCatalog({ root });
const corpus = await loadActiveRegressionCorpus({ root, catalog });
if (corpus.status !== 'ready') {
console.error(
`COMP-09 blocked: regression corpus is ${corpus.status}; `
+ `${corpus.errors.join('; ') || 'no active privacy-reviewed manifest'}`,
);
process.exit(1);
}
for (const fixture of corpus.fixtures) {
const [command, ...args] = fixture.automation.command;
const result = await runCommand(command, args, {
cwd: root,
timeoutMs: Number(process.env.RELEASE_GATE_CORPUS_CASE_TIMEOUT_MS ?? 60_000),
});
if (result.code !== 0 || result.timedOut) {
console.error(`COMP-09 fixture failed: ${fixture.id}`);
console.error(result.stderr || result.stdout);
process.exit(1);
}
console.log(`PASS ${fixture.id} ${fixture.scenario_ids.join(',')}`);
}
console.log(`PASS COMP-09 fixtures=${corpus.fixtures.length} production_connected=false`);
@@ -0,0 +1,60 @@
#!/usr/bin/env node
import mysql from 'mysql2/promise';
import path from 'node:path';
import { assertPortalRuntimePath, hashArtifact } from '../release-gate/artifact.mjs';
import { createLocalGateStack } from '../release-gate/local-stack.mjs';
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
const runtimeRoot = assertPortalRuntimePath(path.join(ROOT, '.runtime', 'portal'), { repoRoot: ROOT });
const artifactBefore = await hashArtifact(runtimeRoot);
const runId = `cold-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`;
const port = Number(process.env.MEMIND_RELEASE_GATE_COLD_PORT ?? 19084);
const stack = await createLocalGateStack({
root: ROOT,
runId,
port,
portalRoot: runtimeRoot,
nodeEnv: 'production',
runtimeProfile: 'production',
});
try {
const firstStatus = await fetch(`${stack.baseUrl}/auth/status`, {
signal: AbortSignal.timeout(5_000),
});
if (!firstStatus.ok) throw new Error(`first health check failed: ${firstStatus.status}`);
const connection = await mysql.createConnection(stack.mysqlUrl);
let initializedTables;
try {
const [rows] = await connection.query(
`SELECT COUNT(*) AS count
FROM information_schema.tables
WHERE table_schema = DATABASE()`,
);
initializedTables = Number(rows[0]?.count ?? 0);
} finally {
await connection.end();
}
if (initializedTables < 10) {
throw new Error(`database initialization is incomplete: ${initializedTables} tables`);
}
await stack.restartPortal();
const restartedStatus = await fetch(`${stack.baseUrl}/auth/status`, {
signal: AbortSignal.timeout(5_000),
});
if (!restartedStatus.ok) throw new Error(`restart health check failed: ${restartedStatus.status}`);
console.log('PASS REL-06 packaged runtime cold-starts with NODE_ENV=production');
console.log(`PASS REL-06 isolated database initialized ${initializedTables} tables and restart is idempotent`);
} finally {
await stack.cleanup();
const artifactAfter = await hashArtifact(runtimeRoot);
if (artifactAfter.sha256 !== artifactBefore.sha256) {
throw new Error(
`packaged runtime mutated its own artifact during cold start: ${artifactBefore.sha256} -> ${artifactAfter.sha256}`,
);
}
}
@@ -0,0 +1,116 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import path from 'node:path';
import { assertPortalRuntimePath, inspectPortalRuntime } from '../release-gate/artifact.mjs';
const root = path.resolve(new URL('..', import.meta.url).pathname);
const runtime = assertPortalRuntimePath(path.join(root, '.runtime', 'portal'), { repoRoot: root });
const image = process.env.RELEASE_GATE_NODE_IMAGE || 'node:24-bookworm';
async function runDocker(commandArgs, { input = '' } = {}) {
return new Promise((resolve, reject) => {
const child = spawn('docker', commandArgs, {
cwd: root,
env: process.env,
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.once('error', reject);
child.once('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
child.stdin.end(input);
});
}
function baseDockerArgs() {
return [
'run',
'--rm',
'-i',
'--network',
'none',
'--mount',
`type=bind,src=${runtime},dst=/opt/portal,readonly`,
'--tmpfs',
'/workspace:rw,noexec,nosuid,size=64m',
image,
];
}
async function requireSuccess(label, args, options) {
const result = await runDocker([...baseDockerArgs(), ...args], options);
if (result.code !== 0) {
throw new Error(`${label} failed (${result.code}): ${result.stderr.slice(-2_000)}`);
}
console.log(`PASS REL-11 ${label}`);
return result.stdout;
}
const inspection = await inspectPortalRuntime(runtime);
if (inspection.missing.length || inspection.forbidden.length) {
throw new Error(
`runtime inspection failed: missing=${inspection.missing.join(',')} forbidden=${inspection.forbidden.join(',')}`,
);
}
await requireSuccess('server bundle parses in Linux ARM64 Node', [
'node',
'--check',
'/opt/portal/server.mjs',
]);
await requireSuccess('WeChat bundle parses in Linux ARM64 Node', [
'node',
'--check',
'/opt/portal/wechat-mp.bundle.mjs',
]);
await requireSuccess('Agent Run worker loads its runtime dependencies', [
'node',
'/opt/portal/scripts/agent-run-worker.mjs',
'--help',
]);
const initialize = `${JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {},
})}\n`;
const sandboxOutput = await requireSuccess(
'sandbox MCP starts and answers initialize',
['node', '/opt/portal/mindspace-sandbox-mcp.mjs', '/workspace'],
{ input: initialize },
);
if (!sandboxOutput.includes('"name":"mindspace-sandbox"')) {
throw new Error('sandbox MCP initialize response is missing server identity');
}
const searchOutput = await requireSuccess(
'MindSearch MCP starts and answers initialize',
['node', '/opt/portal/tkmind-search-mcp.mjs'],
{ input: initialize },
);
if (!searchOutput.includes('"name":"tkmind-search"')) {
throw new Error('MindSearch MCP initialize response is missing server identity');
}
const excelResult = await runDocker(
[
...baseDockerArgs(),
'env',
'EXCEL_ANALYST_ENABLED=1',
'MINDSPACE_WORKSPACE_ROOT=/workspace',
'node',
'/opt/portal/tkmind-excel-mcp.mjs',
'/workspace',
],
{ input: initialize },
);
if (excelResult.code !== 0 || !excelResult.stdout.includes('"name":"tkmind-excel"')) {
throw new Error(`Excel MCP failed to start: ${excelResult.stderr.slice(-2_000)}`);
}
console.log('PASS REL-11 Excel MCP starts and answers initialize');
@@ -0,0 +1,135 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import mysql from 'mysql2/promise';
import pg from 'pg';
import { assertPortalRuntimePath, hashArtifact } from '../release-gate/artifact.mjs';
import { createLocalGateStack } from '../release-gate/local-stack.mjs';
const { Client: PgClient } = pg;
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
const runtimeRoot = assertPortalRuntimePath(path.join(ROOT, '.runtime', 'portal'), { repoRoot: ROOT });
const artifactBefore = await hashArtifact(runtimeRoot);
const runId = `upgrade-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`;
const markerUserId = '00000000-0000-4000-8000-000000000701';
const markerUsername = 'gate_upgrade_fixture';
const markerValue = 'sanitized-page-data-fixture';
const stack = await createLocalGateStack({
root: ROOT,
runId,
port: Number(process.env.MEMIND_RELEASE_GATE_UPGRADE_PORT ?? 19085),
portalRoot: runtimeRoot,
nodeEnv: 'production',
runtimeProfile: 'production',
async beforePortalStart({ mysqlUrl, pgUrl }) {
const schemaSql = await fs.readFile(path.join(ROOT, 'schema.sql'), 'utf8');
const mysqlConnection = await mysql.createConnection({
uri: mysqlUrl,
multipleStatements: true,
});
try {
await mysqlConnection.query(schemaSql);
const now = Date.now();
await mysqlConnection.query(
`INSERT INTO h5_users
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
role, status, plan_type, workspace_root, low_balance_gift_eligible,
low_balance_gift_granted_at, created_at, updated_at)
VALUES (?, ?, ?, NULL, ?, ?, ?, 'pbkdf2-sha512',
'user', 'active', 'free', ?, 0, NULL, ?, ?)`,
[
markerUserId,
markerUsername,
markerUsername,
'脱敏升级测试用户',
'fixture-salt',
'fixture-hash',
`/sanitized/${markerUserId}`,
now,
now,
],
);
await mysqlConnection.query('ALTER TABLE h5_users DROP INDEX uq_h5_users_slug');
await mysqlConnection.query('ALTER TABLE h5_users DROP INDEX uq_h5_users_email');
await mysqlConnection.query(
`ALTER TABLE h5_users
DROP COLUMN slug,
DROP COLUMN email,
DROP COLUMN password_algorithm,
DROP COLUMN plan_type`,
);
} finally {
await mysqlConnection.end();
}
const pgClient = new PgClient({ connectionString: pgUrl });
await pgClient.connect();
try {
await pgClient.query(
`CREATE TABLE gate_sanitized_upgrade_fixture (
id TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
);
await pgClient.query(
'INSERT INTO gate_sanitized_upgrade_fixture (id, value) VALUES ($1, $2)',
['fixture-1', markerValue],
);
} finally {
await pgClient.end();
}
},
});
try {
async function verifyPreservedState() {
const mysqlConnection = await mysql.createConnection(stack.mysqlUrl);
try {
const [users] = await mysqlConnection.query(
`SELECT username, slug, password_algorithm, plan_type
FROM h5_users
WHERE id = ?`,
[markerUserId],
);
if (users.length !== 1) throw new Error('sanitized MySQL user fixture was lost');
if (users[0].slug !== markerUsername) throw new Error('legacy user slug was not migrated');
if (users[0].password_algorithm !== 'pbkdf2-sha512') {
throw new Error('legacy password algorithm default was not restored');
}
if (users[0].plan_type !== 'free') throw new Error('legacy plan default was not restored');
} finally {
await mysqlConnection.end();
}
const pgClient = new PgClient({ connectionString: stack.pgUrl });
await pgClient.connect();
try {
const result = await pgClient.query(
'SELECT value FROM gate_sanitized_upgrade_fixture WHERE id = $1',
['fixture-1'],
);
if (result.rows[0]?.value !== markerValue) {
throw new Error('sanitized PostgreSQL fixture was lost');
}
} finally {
await pgClient.end();
}
}
await verifyPreservedState();
await stack.restartPortal();
await verifyPreservedState();
console.log('PASS REL-07 legacy MySQL columns migrate and sanitized user data is preserved');
console.log('PASS REL-07 Page Data PostgreSQL fixture survives two idempotent packaged-runtime starts');
} finally {
await stack.cleanup();
const artifactAfter = await hashArtifact(runtimeRoot);
if (artifactAfter.sha256 !== artifactBefore.sha256) {
throw new Error(
`packaged runtime mutated its own artifact during upgrade verification: ${artifactBefore.sha256} -> ${artifactAfter.sha256}`,
);
}
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
import { executeReleaseGate, parseRunnerArgs } from '../release-gate/runner.mjs';
function usage() {
console.log(`Usage:
node scripts/run-release-gate.mjs --mode <deterministic|scenarios|browser|providers|upgrade|all> --artifact .runtime/portal [--suite-concurrency 4]
The runner always fails closed. Unknown, skipped, blocked, failed, or unimplemented
scenarios make the command exit non-zero. Production hosts are rejected in code.`);
}
try {
const options = parseRunnerArgs(process.argv);
if (options.help) {
usage();
process.exit(0);
}
const { report, outputDir } = await executeReleaseGate(options);
const summary = report.summary;
console.log(`Release gate report: ${outputDir}`);
console.log(JSON.stringify(summary));
const passed = summary.failed === 0
&& summary.skipped === 0
&& summary.blocked === 0
&& summary.unknown === 0
&& summary.cleanup_failed === 0
&& summary.passed + summary.not_applicable === summary.required;
process.exit(passed ? 0 : 1);
} catch (error) {
console.error(`Release gate failed: ${error.message}`);
process.exit(1);
}
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { assertPortalRuntimePath, hashArtifact } from '../release-gate/artifact.mjs';
import { validateGateReport } from '../release-gate/report.mjs';
import { runCommand } from '../release-gate/runner.mjs';
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
function parseArgs(argv) {
const options = {
artifact: path.join(ROOT, '.runtime', 'portal'),
report: null,
};
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--artifact') options.artifact = path.resolve(argv[++index] ?? '');
else if (arg === '--report') options.report = path.resolve(argv[++index] ?? '');
else throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
async function git(...args) {
const result = await runCommand('git', args, { cwd: ROOT, timeoutMs: 30_000 });
if (result.code !== 0) throw new Error(`git ${args.join(' ')} failed`);
return result.stdout.trim();
}
try {
const options = parseArgs(process.argv);
options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT });
const commitSha = await git('rev-parse', 'HEAD');
const reportPath = options.report ?? path.join(ROOT, '.release-gate', commitSha, 'report.json');
const [raw, artifact] = await Promise.all([
fs.readFile(reportPath, 'utf8'),
hashArtifact(options.artifact),
]);
const report = JSON.parse(raw);
const validation = validateGateReport(report, {
expectedCommit: commitSha,
expectedArtifactSha256: artifact.sha256,
expectedBranch: 'main',
});
if (!validation.valid) {
const blocking = report.scenarios.filter(
(scenario) => !['passed', 'not_applicable'].includes(scenario.status),
);
const preview = blocking
.slice(0, 20)
.map((scenario) => `${scenario.id}:${scenario.status}`)
.join(', ');
const structural = validation.errors.filter(
(message) => !/^[A-Z]+-\d{2} is (failed|skipped|blocked|unknown)$/.test(message),
);
throw new Error([
'Gate report rejected.',
`blocking_scenarios=${blocking.length}`,
`preview=${preview}${blocking.length > 20 ? ', ...' : ''}`,
...structural.map((message) => `- ${message}`),
].join('\n'));
}
console.log(`Gate report verified: ${reportPath}`);
console.log(`commit=${commitSha}`);
console.log(`artifact_sha256=${artifact.sha256}`);
} catch (error) {
console.error(error.message);
process.exit(1);
}