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:
@@ -0,0 +1,159 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const REQUIRED_PORTAL_RUNTIME_PATHS = Object.freeze([
|
||||
'server.mjs',
|
||||
'wechat-mp.bundle.mjs',
|
||||
'mindspace-sandbox-mcp.mjs',
|
||||
'tkmind-search-mcp.mjs',
|
||||
'tkmind-excel-mcp.mjs',
|
||||
'mindspace-public-links.mjs',
|
||||
'dist',
|
||||
'package.json',
|
||||
'scripts/run-memind-portal-prod.sh',
|
||||
'scripts/check-mindspace-public-links.mjs',
|
||||
'scripts/load-env.mjs',
|
||||
'scripts/wechat-mp-menu.mjs',
|
||||
'scripts/memind-portal-tunnel.sh',
|
||||
]);
|
||||
|
||||
export const FORBIDDEN_PORTAL_RUNTIME_PATHS = Object.freeze([
|
||||
'.env',
|
||||
'.git',
|
||||
'data',
|
||||
'dist/dev',
|
||||
'dist/hello-john.html',
|
||||
'users',
|
||||
'logs',
|
||||
'MindSpace',
|
||||
'.tailscale',
|
||||
'public/plaza-covers',
|
||||
]);
|
||||
|
||||
export async function removeForbiddenPortalRuntimePaths(artifactPath) {
|
||||
const root = path.resolve(artifactPath);
|
||||
const segments = root.split(path.sep);
|
||||
if (path.basename(root) !== 'portal' || !segments.includes('.runtime')) {
|
||||
throw new Error('Refusing to sanitize a path outside an explicit .runtime/portal directory');
|
||||
}
|
||||
for (const relative of FORBIDDEN_PORTAL_RUNTIME_PATHS) {
|
||||
await fsp.rm(path.join(root, relative), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPortalRuntimePath(artifactPath, {
|
||||
repoRoot,
|
||||
} = {}) {
|
||||
const expected = path.resolve(repoRoot, '.runtime', 'portal');
|
||||
const actual = path.resolve(artifactPath);
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Portal release gate artifact must be ${expected}`);
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
|
||||
async function sha256File(filePath) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
await new Promise((resolve, reject) => {
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', resolve);
|
||||
});
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
async function walk(root, current = root) {
|
||||
const entries = await fsp.readdir(current, { withFileTypes: true });
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const result = [];
|
||||
for (const entry of entries) {
|
||||
const absolute = path.join(current, entry.name);
|
||||
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
||||
const stat = await fsp.lstat(absolute);
|
||||
if (stat.isDirectory()) {
|
||||
result.push({ type: 'dir', path: relative, mode: stat.mode & 0o777 });
|
||||
result.push(...await walk(root, absolute));
|
||||
} else if (stat.isFile()) {
|
||||
result.push({
|
||||
type: 'file',
|
||||
path: relative,
|
||||
mode: stat.mode & 0o777,
|
||||
size: stat.size,
|
||||
sha256: await sha256File(absolute),
|
||||
});
|
||||
} else if (stat.isSymbolicLink()) {
|
||||
const target = await fsp.readlink(absolute);
|
||||
const resolved = path.resolve(path.dirname(absolute), target);
|
||||
const relativeResolved = path.relative(root, resolved);
|
||||
if (path.isAbsolute(target) || relativeResolved.startsWith(`..${path.sep}`) || relativeResolved === '..') {
|
||||
throw new Error(`Artifact symlink escapes root: ${relative}`);
|
||||
}
|
||||
result.push({ type: 'symlink', path: relative, target });
|
||||
} else {
|
||||
throw new Error(`Unsupported artifact entry: ${relative}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function hashArtifact(artifactPath) {
|
||||
const absolute = path.resolve(artifactPath);
|
||||
const stat = await fsp.stat(absolute);
|
||||
if (stat.isFile()) {
|
||||
return {
|
||||
sha256: await sha256File(absolute),
|
||||
kind: 'file',
|
||||
files: 1,
|
||||
bytes: stat.size,
|
||||
};
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Artifact must be a file or directory: ${absolute}`);
|
||||
}
|
||||
const entries = await walk(absolute);
|
||||
const hash = crypto.createHash('sha256');
|
||||
for (const entry of entries) {
|
||||
hash.update(JSON.stringify(entry));
|
||||
hash.update('\n');
|
||||
}
|
||||
return {
|
||||
sha256: hash.digest('hex'),
|
||||
kind: 'directory-tree',
|
||||
files: entries.filter((entry) => entry.type === 'file').length,
|
||||
bytes: entries.reduce((sum, entry) => sum + (entry.size ?? 0), 0),
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
export async function inspectPortalRuntime(artifactPath) {
|
||||
const root = path.resolve(artifactPath);
|
||||
const stat = await fsp.stat(root);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error('Portal runtime inspection requires a directory artifact');
|
||||
}
|
||||
const missing = [];
|
||||
for (const relative of REQUIRED_PORTAL_RUNTIME_PATHS) {
|
||||
try {
|
||||
await fsp.access(path.join(root, relative));
|
||||
} catch {
|
||||
missing.push(relative);
|
||||
}
|
||||
}
|
||||
const forbidden = [];
|
||||
for (const relative of FORBIDDEN_PORTAL_RUNTIME_PATHS) {
|
||||
try {
|
||||
await fsp.access(path.join(root, relative));
|
||||
forbidden.push(relative);
|
||||
} catch {
|
||||
// Expected: runtime artifacts must not contain persisted production state.
|
||||
}
|
||||
}
|
||||
return {
|
||||
missing,
|
||||
forbidden,
|
||||
passed: missing.length === 0 && forbidden.length === 0,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user