fix: harden release gate and page delivery
Memind CI / Test, build, and release guards (push) Failing after 12m3s
Memind CI / Test, build, and release guards (push) Failing after 12m3s
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
@@ -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);
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user