Files
workinf_Blender_Wasm/tools/web/check-source-archive.mjs
mes123456 7c16b279ae
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
Advance M7 workflows and release operations
2026-08-15 17:43:53 -04:00

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 });
}