166 lines
8.4 KiB
JavaScript
166 lines
8.4 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const releaseRoot = path.join(repoRoot, "release");
|
|
const sourceArchive = path.resolve(process.env.M6_SOURCE_ARCHIVE ?? path.join(releaseRoot, "blender-web-corresponding-source.tar.gz"));
|
|
const binaryArchive = path.resolve(process.env.M6_BINARY_ARCHIVE ?? path.join(releaseRoot, "blender-web-offline.tar.gz"));
|
|
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-source-archive-"));
|
|
const sourceRoot = path.join(workspace, "source");
|
|
const binaryRoot = path.join(workspace, "binary");
|
|
|
|
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
|
|
function archiveEntries(archive) {
|
|
const entries = execFileSync("tar", ["-tzf", archive], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }).split("\n").filter(Boolean);
|
|
assert.ok(entries.length > 0, `${path.basename(archive)} is empty`);
|
|
for (const entry of entries) {
|
|
assert.ok(!entry.startsWith("/") && !entry.split("/").includes(".."), `unsafe archive path: ${entry}`);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function run(command, args, cwd) {
|
|
const result = spawnSync(command, args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
});
|
|
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
|
assert.equal(result.status, 0, `${command} ${args.join(" ")} failed\n${output.slice(-8000)}`);
|
|
return output;
|
|
}
|
|
|
|
function firstPartyTextFiles(root) {
|
|
const files = [];
|
|
const visit = (directory) => {
|
|
if (!fs.existsSync(directory)) return;
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const absolute = path.join(directory, entry.name);
|
|
const relative = path.relative(root, absolute).replaceAll(path.sep, "/");
|
|
if (relative.startsWith("web/app/public/vendor/") || relative.startsWith("web/app/src/vendor/")) continue;
|
|
if (entry.isDirectory()) visit(absolute);
|
|
else if (entry.isFile() && fs.statSync(absolute).size <= 2 * 1024 * 1024 && /\.(?:c|cc|cpp|h|hh|hpp|js|mjs|json|md|sh|ts|tsx|txt|yml|yaml)$/.test(entry.name)) files.push(absolute);
|
|
}
|
|
};
|
|
for (const relative of ["web", "tools/web", "docs/web"]) visit(path.join(root, relative));
|
|
for (const relative of ["docs/PROJECT_STATUS_AND_NEXT_WORK.md", "WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md"]) {
|
|
const file = path.join(root, relative);
|
|
if (fs.existsSync(file)) files.push(file);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function distManifest(root) {
|
|
const files = [];
|
|
const visit = (directory, base) => {
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
const absolute = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) visit(absolute, base);
|
|
else files.push({ path: path.relative(base, absolute).replaceAll(path.sep, "/"), bytes: fs.statSync(absolute).size, sha256: sha256(absolute) });
|
|
}
|
|
};
|
|
visit(root, root);
|
|
return files;
|
|
}
|
|
|
|
try {
|
|
assert.ok(fs.statSync(sourceArchive).isFile(), `source archive is missing: ${sourceArchive}`);
|
|
assert.ok(fs.statSync(binaryArchive).isFile(), `binary archive is missing: ${binaryArchive}`);
|
|
const entries = archiveEntries(sourceArchive);
|
|
const forbidden = entries.filter((entry) =>
|
|
/(^|\/)(?:node_modules|dist|test-results|playwright-report|__pycache__)(?:\/|$)/.test(entry) ||
|
|
/\.pyc$/.test(entry) ||
|
|
/^(?:release|build_[^/]*|\.emcache)(?:\/|$)/.test(entry),
|
|
);
|
|
assert.deepEqual(forbidden, [], `source archive contains generated output: ${forbidden.join(", ")}`);
|
|
|
|
fs.mkdirSync(sourceRoot);
|
|
fs.mkdirSync(binaryRoot);
|
|
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", sourceArchive, "-C", sourceRoot]);
|
|
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", binaryArchive, "-C", binaryRoot]);
|
|
assert.ok(!fs.realpathSync(sourceRoot).startsWith(`${repoRoot}${path.sep}`), "source archive was not extracted independently");
|
|
|
|
const required = [
|
|
"SOURCE_MANIFEST.json",
|
|
"blender-5.2.0/COPYING",
|
|
"blender-5.2.0/CMakeLists.txt",
|
|
"blender-5.2.0/source/blender/web_engine/CMakeLists.txt",
|
|
"web/package.json",
|
|
"web/package-lock.json",
|
|
"web/app/vite.config.ts",
|
|
"web/app/src/main.tsx",
|
|
"web/protocol/manifest.ts",
|
|
"tools/web/create-offline-release.mjs",
|
|
];
|
|
for (const relative of required) assert.ok(fs.lstatSync(path.join(sourceRoot, relative)), `source archive omits ${relative}`);
|
|
const sourceOffer = fs.readFileSync(path.join(binaryRoot, "blender-web-offline/SOURCE_OFFER.txt"), "utf8");
|
|
assert.match(sourceOffer, /blender-web-corresponding-source\.tar\.gz/);
|
|
assert.match(sourceOffer, /Blender 5\.2/);
|
|
|
|
const manifestPath = path.join(sourceRoot, "SOURCE_MANIFEST.json");
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
assert.equal(manifest.schemaVersion, 1);
|
|
assert.equal(manifest.product, "Web Blender Modeler V1");
|
|
assert.ok(Array.isArray(manifest.files) && manifest.files.length > 10_000, "source manifest is incomplete");
|
|
const actualPaths = entries.filter((entry) => !entry.endsWith("/") && entry !== "SOURCE_MANIFEST.json").sort();
|
|
assert.deepEqual(manifest.files.map((entry) => entry.path).sort(), actualPaths, "source manifest path list drifted");
|
|
for (const entry of manifest.files) {
|
|
const file = path.join(sourceRoot, entry.path);
|
|
if (entry.type === "symlink") {
|
|
assert.equal(fs.readlinkSync(file), entry.target, `source symlink target drifted: ${entry.path}`);
|
|
}
|
|
else {
|
|
assert.equal(fs.statSync(file).size, entry.bytes, `source byte length drifted: ${entry.path}`);
|
|
assert.equal(sha256(file), entry.sha256, `source SHA-256 drifted: ${entry.path}`);
|
|
}
|
|
}
|
|
|
|
const absoluteDevelopmentPath = /(?:\/home\/[A-Za-z0-9._-]+\/|\/Users\/[A-Za-z0-9._-]+\/|[A-Za-z]:\\Users\\)/;
|
|
const credential = /(?:-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----|ghp_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16})/;
|
|
for (const file of firstPartyTextFiles(sourceRoot)) {
|
|
const text = fs.readFileSync(file, "utf8");
|
|
assert.doesNotMatch(text, absoluteDevelopmentPath, `source archive contains an absolute development path: ${path.relative(sourceRoot, file)}`);
|
|
assert.doesNotMatch(text, credential, `source archive contains a credential signature: ${path.relative(sourceRoot, file)}`);
|
|
}
|
|
|
|
const installOutput = run("npm", ["ci", "--ignore-scripts"], path.join(sourceRoot, "web"));
|
|
const typecheckOutput = run("npm", ["run", "typecheck"], path.join(sourceRoot, "web"));
|
|
const buildOutput = run("npm", ["run", "build"], path.join(sourceRoot, "web"));
|
|
const rebuilt = distManifest(path.join(sourceRoot, "web/dist"));
|
|
const packaged = distManifest(path.join(binaryRoot, "blender-web-offline/app"));
|
|
assert.deepEqual(rebuilt, packaged, "source archive build does not reproduce the binary app manifest");
|
|
|
|
const report = {
|
|
schemaVersion: 1,
|
|
task: "M6-12",
|
|
status: "READY",
|
|
archive: { path: path.basename(sourceArchive), bytes: fs.statSync(sourceArchive).size, sha256: sha256(sourceArchive) },
|
|
binaryArchiveSha256: sha256(binaryArchive),
|
|
extractedRootOutsideWorkspace: true,
|
|
filesVerified: manifest.files.length,
|
|
buildFilesVerified: rebuilt.length,
|
|
commands: [
|
|
{ command: "npm ci --ignore-scripts", outputSha256: crypto.createHash("sha256").update(installOutput).digest("hex") },
|
|
{ command: "npm run typecheck", outputSha256: crypto.createHash("sha256").update(typecheckOutput).digest("hex") },
|
|
{ command: "npm run build", outputSha256: crypto.createHash("sha256").update(buildOutput).digest("hex") },
|
|
],
|
|
keyFiles: Object.fromEntries(required.map((relative) => [relative, sha256(path.join(sourceRoot, relative))])),
|
|
rebuiltManifestSha256: crypto.createHash("sha256").update(JSON.stringify(rebuilt)).digest("hex"),
|
|
};
|
|
const reportRoot = path.join(releaseRoot, "archive-reports");
|
|
fs.mkdirSync(reportRoot, { recursive: true });
|
|
fs.writeFileSync(path.join(reportRoot, "source.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
process.stdout.write(`source-archive-ok files=${report.filesVerified} buildFiles=${report.buildFilesVerified} sha256=${report.archive.sha256}\n`);
|
|
}
|
|
finally {
|
|
fs.rmSync(workspace, { recursive: true, force: true });
|
|
}
|
|
|