Files
memind/release-gate/release-script.test.mjs
T
john 046704816f
Memind CI / Test, build, and release guards (push) Successful in 1m41s
fix: use container MCP path in portal canary
2026-07-26 22:34:58 +08:00

245 lines
11 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
const CANARY_RELEASE = path.join(ROOT, 'scripts', 'release-portal-canary-prod.sh');
const CANARY_ROLLBACK = path.join(ROOT, 'scripts', 'rollback-portal-canary-prod.sh');
function heredocBody(source, opener, closer) {
const start = source.indexOf(opener);
assert.ok(start >= 0, `missing heredoc opener: ${opener}`);
const bodyStart = source.indexOf('\n', start) + 1;
const end = source.indexOf(`\n${closer}\n`, bodyStart);
assert.ok(bodyStart > 0 && end >= bodyStart, `missing heredoc closer: ${closer}`);
return source.slice(bodyStart, end);
}
function assertShellParses(source, label) {
const result = spawnSync('bash', ['-n'], {
cwd: ROOT,
encoding: 'utf8',
input: source,
});
assert.equal(result.status, 0, `${label} failed bash -n:\n${result.stderr}`);
}
test('production release verifies gate report before any 103 connection', async () => {
const source = await fs.readFile(
path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'),
'utf8',
);
const gateIndex = source.indexOf('verify-release-gate-report.mjs');
const preflightIndex = source.indexOf('执行 103 只读预检');
const uploadIndex = source.indexOf('上传 Portal runtime 到 103');
assert.ok(gateIndex > 0, 'missing gate verifier');
assert.ok(preflightIndex > gateIndex, '103 preflight must run after gate verification');
assert.ok(uploadIndex > preflightIndex, 'upload must run after preflight');
assert.match(source, /生产发布守门员禁止 --skip-tests/);
assert.match(source, /禁止 ALLOW_PORTAL_RELEASE_SCOPE_BYPASS/);
});
test('runtime builder removes every persisted path forbidden by artifact policy', async () => {
const source = await fs.readFile(
path.join(ROOT, 'scripts', 'build-portal-runtime.mjs'),
'utf8',
);
assert.match(source, /removeForbiddenPortalRuntimePaths/);
assert.match(source, /removeForbiddenPortalRuntimePaths\(runtimeRoot\)/);
});
test('runtime builder materializes and declares required Linux ARM64 native packages', async () => {
const source = await fs.readFile(
path.join(ROOT, 'scripts', 'build-portal-runtime.mjs'),
'utf8',
);
assert.match(source, /@resvg\/resvg-js-linux-arm64-gnu/);
assert.match(source, /@node-rs\/argon2-linux-arm64-gnu/);
assert.match(source, /installLinuxArm64NativePackages\(\)/);
assert.match(source, /optionalDependencies: runtimeOptionalDependencies/);
assert.match(source, /缺少原生 \.node 文件/);
});
test('packaged runtime gate isolates persistent roots and rejects artifact mutation', async () => {
const localStackSource = await fs.readFile(
path.join(ROOT, 'release-gate', 'local-stack.mjs'),
'utf8',
);
assert.match(localStackSource, /MEMIND_PORTAL_H5_ROOT: sandboxRoot/);
assert.match(localStackSource, /MEMIND_DEEPSEEK_DISABLE_THINKING: '1'/);
assert.match(localStackSource, /deepseek-no-think-proxy\.mjs/);
assert.match(localStackSource, /MEMIND_ORCHESTRATOR_MODE: 'shadow'/);
assert.match(localStackSource, /MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1'/);
assert.match(localStackSource, /H5_USERS_ROOT: usersRoot/);
assert.match(localStackSource, /MINDSPACE_STORAGE_ROOT: storageRoot/);
assert.match(localStackSource, /MEMIND_SHARED_PUBLISH_ROOT: publishRoot/);
for (const scriptName of [
'run-release-gate-runtime-cold-start.mjs',
'run-release-gate-runtime-upgrade.mjs',
]) {
const scriptSource = await fs.readFile(path.join(ROOT, 'scripts', scriptName), 'utf8');
assert.match(scriptSource, /const artifactBefore = await hashArtifact\(runtimeRoot\)/);
assert.match(scriptSource, /const artifactAfter = await hashArtifact\(runtimeRoot\)/);
assert.match(scriptSource, /artifactAfter\.sha256 !== artifactBefore\.sha256/);
}
});
test('production release rejects --skip-tests before repository or network preflight', () => {
const result = spawnSync(
'bash',
[path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'), '--skip-tests'],
{ cwd: ROOT, encoding: 'utf8' },
);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /禁止 --skip-tests/);
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /103 只读预检/);
});
test('production canary verifies the exact Gate artifact before any 103 preflight or upload', async () => {
const source = await fs.readFile(CANARY_RELEASE, 'utf8');
const gateIndex = source.indexOf('verify-release-gate-report.mjs');
const preflightIndex = source.indexOf('Run 103 read-only preflight');
const uploadIndex = source.indexOf('Upload the verified candidate bundle');
assert.ok(gateIndex > 0, 'missing canary Gate verifier');
assert.ok(preflightIndex > gateIndex, '103 preflight must follow Gate verification');
assert.ok(uploadIndex > preflightIndex, 'upload must follow read-only preflight');
assert.match(source, /branch.*!= "main"/);
assert.match(source, /rev-parse origin\/main/);
assert.match(source, /Production canary release forbids/);
});
test('production canary keeps stable 8081 live and switches only after verified backups and fallback', async () => {
const source = await fs.readFile(CANARY_RELEASE, 'utf8');
const fullBackup = source.indexOf('Create and verify the full stable backup');
const persistBackup = source.indexOf('Create and verify the persisted-data backup');
const edgeBackup = source.indexOf('Create and verify the active 105 nginx routing backup');
const goosedStart = source.indexOf('Start an isolated goosed candidate on 18015');
const candidateStart = source.indexOf('Start the passive candidate Portal on 18081');
const proxyStart = source.indexOf('Start the fail-closed identity router on 18082');
const tunnelStart = source.indexOf('Start the isolated 105 reverse tunnel on 19082');
const edgeSwitch = source.indexOf(
'Switch the committed 105 nginx upstreams to the isolated canary tunnel',
);
const fallbackProbe = source.indexOf('route-probe?username=john');
assert.ok(fullBackup > 0);
assert.ok(persistBackup > fullBackup);
assert.ok(edgeBackup > persistBackup);
assert.ok(goosedStart > edgeBackup);
assert.ok(candidateStart > goosedStart);
assert.ok(proxyStart > candidateStart);
assert.ok(tunnelStart > proxyStart);
assert.ok(edgeSwitch > tunnelStart);
assert.ok(fallbackProbe > edgeSwitch);
assert.match(source, /restore_edge_to_stable/);
assert.match(source, /proxy_pass http:\/\/58\.38\.22\.103:8081;/);
assert.match(source, /proxy_pass http:\/\/127\.0\.0\.1:\$\{CANARY_TUNNEL_REMOTE_PORT\};/);
assert.match(source, /nginx -t/);
assert.match(source, /CANARY_PROXY_PORT=18082/);
assert.match(source, /CANARY_TUNNEL_REMOTE_PORT=19082/);
assert.match(
source,
/edge_ssh\(\) \{\s*#.*\n\s*#.*\n\s*ssh -n -o BatchMode=yes/,
);
assert.match(
source,
/ssh -o BatchMode=yes -o ConnectTimeout=10 "\$\{EDGE_HOST\}" \/bin\/bash <<EDGE_SWITCH/,
);
assert.doesNotMatch(source, /edge_ssh \/bin\/bash <<EDGE_SWITCH/);
assert.match(source, /CANARY_USERNAMES="john"/);
assert.match(source, /CANARY_WECHAT_USER_IDS="wx_ul610et8"/);
assert.match(source, /route-probe\?\$\{probe_query\}/);
assert.match(source, /candidate_healthy/);
assert.match(source, /MEMIND_CANARY_RELEASE_ID/);
assert.match(
source,
/if restore_edge_to_stable >\/dev\/null 2>&1; then\s+stop_candidate_services/,
);
assert.match(source, /keeping canary services running/);
assert.match(source, /--resolve wechat\.m\.tkmind\.cn:443:127\.0\.0\.1/);
assert.doesNotMatch(source, /lsof -tiTCP:8081/);
assert.doesNotMatch(source, /bootout.*cn\.tkmind\.memind-portal/);
});
test('candidate runner overrides stable host MCP paths with container-visible paths', async (t) => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-canary-runner-'));
t.after(() => fs.rm(tempRoot, { recursive: true, force: true }));
const stableRoot = path.join(tempRoot, 'stable');
const fakeNode = path.join(tempRoot, 'node');
await fs.mkdir(stableRoot);
await fs.writeFile(
path.join(stableRoot, '.env'),
[
'GOOSED_MCP_NODE_PATH=/opt/homebrew/opt/node@24/bin/node',
'GOOSED_MCP_SERVER_PATH=/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs',
'',
].join('\n'),
);
await fs.writeFile(
fakeNode,
[
'#!/usr/bin/env bash',
'printf "cwd=%s\\n" "$PWD"',
'printf "mcp_node=%s\\n" "$GOOSED_MCP_NODE_PATH"',
'printf "mcp_server=%s\\n" "$GOOSED_MCP_SERVER_PATH"',
'printf "entrypoint=%s\\n" "$1"',
'',
].join('\n'),
{ mode: 0o755 },
);
const result = spawnSync(
'bash',
[path.join(ROOT, 'scripts', 'run-memind-portal-candidate.sh')],
{
cwd: '/',
encoding: 'utf8',
env: {
...process.env,
MEMIND_CANARY_STABLE_ROOT: stableRoot,
MEMIND_CANARY_RELEASE_ID: 'test-release',
MEMIND_CANARY_CANDIDATE_PORT: '65534',
NODE_BIN: fakeNode,
},
},
);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`^cwd=${ROOT}$`, 'm'));
assert.match(result.stdout, /^mcp_node=\/usr\/local\/bin\/node$/m);
assert.match(
result.stdout,
/^mcp_server=\/opt\/portal\/mindspace-sandbox-mcp\.mjs$/m,
);
assert.match(result.stdout, new RegExp(`^entrypoint=${ROOT}/server\\.mjs$`, 'm'));
});
test('canary release and rollback remote shells remain syntactically valid', async () => {
const releaseSource = await fs.readFile(CANARY_RELEASE, 'utf8');
const rollbackSource = await fs.readFile(CANARY_ROLLBACK, 'utf8');
const releaseRemote = heredocBody(releaseSource, "<<'REMOTE_SCRIPT'", 'REMOTE_SCRIPT');
const rollbackRemote = heredocBody(rollbackSource, "<<'REMOTE'", 'REMOTE');
const edgeSwitch = heredocBody(releaseRemote, '<<EDGE_SWITCH', 'EDGE_SWITCH')
.replaceAll('\\$', '$');
const edgeRollback = heredocBody(rollbackRemote, '<<EDGE_ROLLBACK', 'EDGE_ROLLBACK')
.replaceAll('\\$', '$');
assertShellParses(releaseRemote, 'release remote shell');
assertShellParses(rollbackRemote, 'rollback remote shell');
assertShellParses(edgeSwitch, '105 edge switch shell');
assertShellParses(edgeRollback, '105 edge rollback shell');
});
test('release readiness ignores only the generated canary artifact cover path', async () => {
const source = await fs.readFile(
path.join(ROOT, 'scripts', 'check-release-ready.sh'),
'utf8',
);
assert.match(source, /:\(exclude\)\.runtime\/portal\/public\/plaza-covers\/\*\*/);
assert.doesNotMatch(source, /:\(exclude\)\.runtime\/\*\*/);
});