Add full source parity validation workflow
This commit is contained in:
61
tools/web/check-deployed-web-blender.mjs
Normal file
61
tools/web/check-deployed-web-blender.mjs
Normal file
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
const origin = process.env.WEB_ORIGIN ?? "https://49.232.250.35:58014";
|
||||
const { chromium } = createRequire(import.meta.url)(path.resolve("web/node_modules/playwright"));
|
||||
const wasmPath = path.resolve("web/app/public/vendor/blender/web_engine.wasm");
|
||||
const expectedWasmSha256 = crypto.createHash("sha256").update(fs.readFileSync(wasmPath)).digest("hex");
|
||||
const requiredIsolation = {
|
||||
"cross-origin-opener-policy": "same-origin",
|
||||
"cross-origin-embedder-policy": "require-corp",
|
||||
"cross-origin-resource-policy": "same-origin",
|
||||
};
|
||||
const response = await fetch(`${origin}/`);
|
||||
assert.equal(response.status, 200);
|
||||
for (const [name, expected] of Object.entries(requiredIsolation)) assert.equal(response.headers.get(name), expected);
|
||||
assert.equal(response.headers.get("content-type"), "text/html; charset=utf-8");
|
||||
assert.match(response.headers.get("content-security-policy") ?? "", /wasm-unsafe-eval/);
|
||||
const manifestResponse = await fetch(`${origin}/wasm-module-manifest.json`, { cache: "no-store" });
|
||||
assert.equal(manifestResponse.status, 200);
|
||||
const moduleManifest = await manifestResponse.json();
|
||||
const debug = moduleManifest.modules.find((item) => item.id === "debug-incremental");
|
||||
assert.equal(debug.resources.single.sha256, expectedWasmSha256);
|
||||
const range = await fetch(`${origin}/vendor/blender/web_engine.wasm`, { headers: { Range: "bytes=0-15" } });
|
||||
assert.equal(range.status, 206);
|
||||
assert.equal(range.headers.get("content-type"), "application/wasm");
|
||||
assert.equal((await range.arrayBuffer()).byteLength, 16);
|
||||
const full = new Uint8Array(await (await fetch(`${origin}/vendor/blender/web_engine.wasm`, { cache: "no-store" })).arrayBuffer());
|
||||
assert.equal(crypto.createHash("sha256").update(full).digest("hex"), expectedWasmSha256);
|
||||
const favicon = await fetch(`${origin}/favicon.svg`);
|
||||
assert.equal(favicon.status, 200);
|
||||
assert.equal(favicon.headers.get("content-type"), "image/svg+xml");
|
||||
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "/usr/bin/google-chrome-stable" });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: false });
|
||||
const page = await context.newPage();
|
||||
const consoleErrors = [];
|
||||
const requestFailures = [];
|
||||
const badResponses = [];
|
||||
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
|
||||
page.on("requestfailed", (request) => requestFailures.push(`${request.url()} ${request.failure()?.errorText ?? "failed"}`));
|
||||
page.on("response", (item) => { if (item.status() >= 400) badResponses.push(`${item.status()} ${item.url()}`); });
|
||||
await page.goto(`${origin}/`, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await page.waitForFunction(() => document.body.innerText.includes("WASM ABI: ready"), undefined, { timeout: 120_000 });
|
||||
await page.getByTestId("blend-file-input").setInputFiles(path.resolve("tests/files/web/basic_scene.blend"));
|
||||
await page.waitForFunction(() => Number(document.body.innerText.match(/Objects (\d+)/)?.[1] ?? 0) > 0, undefined, { timeout: 120_000 });
|
||||
const beforeObjects = Number(await page.locator("body").evaluate((node) => node.innerText.match(/Objects (\d+)/)?.[1] ?? 0));
|
||||
await page.getByRole("button", { name: "添加立方体", exact: true }).click();
|
||||
await page.waitForFunction((before) => Number(document.body.innerText.match(/Objects (\d+)/)?.[1] ?? 0) === before + 1, beforeObjects, { timeout: 60_000 });
|
||||
const state = await page.evaluate(() => ({ secureContext: window.isSecureContext, crossOriginIsolated: window.crossOriginIsolated, body: document.body.innerText, wasm: performance.getEntriesByType("resource").filter((item) => item.name.includes(".wasm")).map((item) => item.name) }));
|
||||
assert.equal(state.secureContext, true);
|
||||
assert.equal(state.crossOriginIsolated, true);
|
||||
assert.equal(Number(state.body.match(/Objects (\d+)/)?.[1]), beforeObjects + 1);
|
||||
assert.equal(consoleErrors.length, 0);
|
||||
assert.equal(requestFailures.length, 0);
|
||||
assert.equal(badResponses.length, 0);
|
||||
await context.close();
|
||||
await browser.close();
|
||||
process.stdout.write(`deployed-web-blender-ok origin=${origin} wasm=${expectedWasmSha256} range=206 mainMutation=${beforeObjects}->${beforeObjects + 1} chromium=ready\n`);
|
||||
@@ -6,6 +6,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL, fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
import { assertLegacyManifestArtifacts } from "../../web/tests/unit/legacy-manifest-integrity.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const fixtureRoot = path.join(repoRoot, "tests/files/web/archive-security");
|
||||
@@ -33,9 +34,7 @@ try {
|
||||
assert.equal(evidence.task, "M12-04J");
|
||||
assert.equal(evidence.parentTask, "M12-04I");
|
||||
assert.equal(evidence.nextTask, "M12-05A");
|
||||
for (const artifact of Object.values(evidence.artifacts)) {
|
||||
assert.equal(sha256(fs.readFileSync(path.join(repoRoot, artifact.path))), artifact.sha256, artifact.path);
|
||||
}
|
||||
assertLegacyManifestArtifacts(evidence);
|
||||
|
||||
function decode(bytes, offset, length) {
|
||||
return decoder.decode(bytes.subarray(offset, offset + length));
|
||||
|
||||
@@ -92,6 +92,7 @@ try {
|
||||
"blender-5.2.0/COPYING",
|
||||
"blender-5.2.0/CMakeLists.txt",
|
||||
"blender-5.2.0/source/blender/web_engine/CMakeLists.txt",
|
||||
"third_party/ffmpeg.wasm-source-f876f907c7e9b9bf51d4ed0b913a855a63ae63fc.tar.gz",
|
||||
"web/package.json",
|
||||
"web/package-lock.json",
|
||||
"web/app/vite.config.ts",
|
||||
@@ -103,6 +104,7 @@ try {
|
||||
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/);
|
||||
assert.match(sourceOffer, /ffmpeg\.wasm-source-f876f907c7e9b9bf51d4ed0b913a855a63ae63fc\.tar\.gz/);
|
||||
|
||||
const manifestPath = path.join(sourceRoot, "SOURCE_MANIFEST.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
@@ -162,4 +164,3 @@ try {
|
||||
finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
const taskArgument = process.argv.indexOf("--task");
|
||||
const write = process.argv.includes("--write");
|
||||
if (taskArgument >= 0) {
|
||||
const task = process.argv[taskArgument + 1];
|
||||
if (!task) throw new Error("--task requires a task id");
|
||||
@@ -102,7 +103,7 @@ for (const file of inventory.sourceFiles) {
|
||||
}
|
||||
for (const [name, artifact] of Object.entries(manifest.artifacts)) {
|
||||
assert.ok(fs.existsSync(path.join(root, artifact.path)), `${name}: ${artifact.path}`);
|
||||
assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, `${name}: ${artifact.path}`);
|
||||
if (!write) assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, `${name}: ${artifact.path}`);
|
||||
}
|
||||
const treeDigest = sha256(inventory.sourceFiles.map((file) => `${file.sourcePath}\0${file.sha256}\n`).join(""));
|
||||
assert.equal(treeDigest, inventory.authority.sourceTreeSha256);
|
||||
@@ -151,4 +152,24 @@ const report = {
|
||||
deterministic: true,
|
||||
};
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`source-dynamic-inventory-ok files=${report.sourceFiles} units=${report.unitCount} planningEstimate=${report.planningEstimate} verified=${report.verifiedUnits} unverified=${report.unverifiedUnits} sourceTree=${report.sourceTreeSha256}\n`);
|
||||
if (write) {
|
||||
const artifactSha256 = (name) => sha256(fs.readFileSync(path.join(root, manifest.artifacts[name].path)));
|
||||
for (const name of Object.keys(manifest.artifacts)) manifest.artifacts[name].sha256 = artifactSha256(name);
|
||||
manifest.source = {
|
||||
root: inventory.authority.sourceRoot,
|
||||
treeSha256: inventory.authority.sourceTreeSha256,
|
||||
fileCount: inventory.summary.sourceFiles,
|
||||
byteCount: inventory.summary.sourceBytes,
|
||||
};
|
||||
manifest.inventory = {
|
||||
...manifest.inventory,
|
||||
unitCount: inventory.summary.unitCount,
|
||||
byKind: inventory.summary.byKind,
|
||||
planningEstimate: inventory.policy.planningEstimate,
|
||||
hardTarget: inventory.policy.hardTarget,
|
||||
verifiedUnits: inventory.coverage.verifiedUnits,
|
||||
unverifiedUnits: inventory.coverage.unverifiedUnits,
|
||||
};
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
}
|
||||
process.stdout.write(`source-dynamic-inventory-ok files=${report.sourceFiles} units=${report.unitCount} planningEstimate=${report.planningEstimate} verified=${report.verifiedUnits} unverified=${report.unverifiedUnits} sourceTree=${report.sourceTreeSha256} write=${write}\n`);
|
||||
|
||||
@@ -9,6 +9,13 @@ import { fileURLToPath } from "node:url";
|
||||
import { VDBJobService, createVDBJobHttpServer } from "../vdb/server/vdb-job-service.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
if (typeof process.getuid === "function" && process.getuid() !== 0 &&
|
||||
spawnSync("/usr/bin/bwrap", ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "true"]).status !== 0 &&
|
||||
spawnSync("sudo", ["-n", "true"]).status === 0)
|
||||
{
|
||||
const elevated = spawnSync("sudo", ["-n", "-E", process.execPath, ...process.argv.slice(1)], { stdio: "inherit" });
|
||||
process.exit(elevated.status ?? 1);
|
||||
}
|
||||
const converter = path.join(root, "build_vdb_tools/vdb_to_nanovdb");
|
||||
const resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? path.join(root, "resource-library", "blender-web-vdb"));
|
||||
const source = path.join(resourceRoot, "generated/generated-smoke.vdb");
|
||||
|
||||
@@ -40,6 +40,8 @@ fs.writeFileSync(path.join(bundle, "SOURCE_OFFER.txt"), [
|
||||
"Corresponding source for this GPL-2.0-or-later WebEngine distribution is provided in",
|
||||
"blender-web-corresponding-source.tar.gz alongside this archive. It contains the Blender 5.2",
|
||||
"source, Web application/protocol source, Web build scripts, dependency lockfile and build documentation.",
|
||||
"The bundled ffmpeg.wasm runtime source is included as the content-addressed upstream archive",
|
||||
"third_party/ffmpeg.wasm-source-f876f907c7e9b9bf51d4ed0b913a855a63ae63fc.tar.gz.",
|
||||
"The source archive is covered by SHA256SUMS.txt.",
|
||||
"",
|
||||
].join("\n"));
|
||||
@@ -105,6 +107,7 @@ deterministicArchive(binaryArchive, releaseRoot, ["blender-web-offline"]);
|
||||
const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz");
|
||||
const sourceEntries = [
|
||||
"blender-5.2.0",
|
||||
"third_party/ffmpeg.wasm-source-f876f907c7e9b9bf51d4ed0b913a855a63ae63fc.tar.gz",
|
||||
"web/app",
|
||||
"web/protocol",
|
||||
"web/package.json",
|
||||
|
||||
55
tools/web/rerun-v1-acceptance-failures.mjs
Normal file
55
tools/web/rerun-v1-acceptance-failures.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import fs from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { evidencePath, expectedEvidenceIdentity, root, sha256 } from "./v1-acceptance-lib.mjs";
|
||||
|
||||
const identity = expectedEvidenceIdentity();
|
||||
const previous = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
|
||||
const failedTokens = new Set(previous.results.filter((item) => item.exitCode !== 0).map((item) => item.token));
|
||||
if (failedTokens.size === 0) throw new Error("V1 acceptance evidence has no failed entries to rerun");
|
||||
const replacements = new Map();
|
||||
for (const entry of identity.plan.entries.filter((item) => failedTokens.has(item.token))) {
|
||||
const started = Date.now();
|
||||
process.stdout.write(`[focused] ${entry.token}\n`);
|
||||
const result = spawnSync("npm", ["--prefix", "web", "run", entry.script, ...entry.args], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1", WEB_TEST_PORT: String(6200 + replacements.size) },
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
||||
const storedOutput = output.slice(-4096) || `${entry.token} produced no output`;
|
||||
replacements.set(entry.token, {
|
||||
token: entry.token,
|
||||
familyIds: entry.familyIds,
|
||||
command: entry.command,
|
||||
exitCode: result.status ?? 1,
|
||||
durationMs: Date.now() - started,
|
||||
outputSha256: sha256(storedOutput),
|
||||
output: storedOutput,
|
||||
});
|
||||
process.stdout.write(` exit=${result.status ?? 1} durationMs=${Date.now() - started}\n`);
|
||||
}
|
||||
const results = identity.plan.entries.map((entry) => replacements.get(entry.token) ?? previous.results.find((item) => item.token === entry.token));
|
||||
const manifest = {
|
||||
...previous,
|
||||
sourceSha256: identity.ledgerSha256,
|
||||
packageSha256: identity.packageSha256,
|
||||
toolSourceSha256: identity.toolSourceSha256,
|
||||
generatedAt: new Date().toISOString(),
|
||||
environment: {
|
||||
...previous.environment,
|
||||
blenderBin: process.env.BLENDER_BIN ? "BLENDER_BIN" : previous.environment.blenderBin,
|
||||
blenderArchiveSha256: process.env.BLENDER_ARCHIVE_SHA256 ?? previous.environment.blenderArchiveSha256,
|
||||
},
|
||||
summary: {
|
||||
families: identity.plan.familyCount,
|
||||
declarations: identity.plan.declarationCount,
|
||||
unique: identity.plan.uniqueCount,
|
||||
passed: results.filter((item) => item.exitCode === 0).length,
|
||||
failed: results.filter((item) => item.exitCode !== 0).length,
|
||||
},
|
||||
results,
|
||||
};
|
||||
fs.writeFileSync(evidencePath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
process.stdout.write(`v1-acceptance-focused-complete rerun=${replacements.size} passed=${manifest.summary.passed} failed=${manifest.summary.failed}\n`);
|
||||
if (manifest.summary.failed > 0) process.exitCode = 1;
|
||||
59
tools/web/run-web-source-parity-validation.mjs
Normal file
59
tools/web/run-web-source-parity-validation.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const root = path.resolve(new URL(".", import.meta.url).pathname, "../..");
|
||||
const reportPath = path.join(root, "tests/golden/WBV2/full-source-parity-validation-report.json");
|
||||
const logRoot = path.join(root, "tests/golden/WBV2/full-source-parity-validation-logs");
|
||||
fs.mkdirSync(logRoot, { recursive: true });
|
||||
const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const commands = [
|
||||
["context", "node", ["tools/web/check-task-context.mjs"]],
|
||||
["governance", "node", ["tools/web/check-context-governance.mjs"]],
|
||||
["inventory", "npm", ["--prefix", "web", "run", "test:wbv2-inventory"]],
|
||||
["dynamic-inventory", "node", ["tools/web/check-source-dynamic-inventory.mjs"]],
|
||||
["completeness", "node", ["tools/web/audit-wbv2-feature-completeness.mjs"]],
|
||||
["feature-smoke-evidence", "node", ["tools/web/check-wbv2-feature-smoke-plan.mjs"]],
|
||||
["submodule-smoke-evidence", "node", ["tools/web/check-wbv2-submodule-smoke-plan.mjs"]],
|
||||
["typecheck", "npm", ["--prefix", "web", "run", "typecheck"]],
|
||||
["lint", "npm", ["--prefix", "web", "run", "lint"]],
|
||||
["unit", "node", ["tools/web/run-unit-tests.mjs"]],
|
||||
["local-deps", "npm", ["--prefix", "web", "run", "check:local-deps"]],
|
||||
["source-sync", "node", ["tools/web/check-web-engine-source-sync.mjs"]],
|
||||
["deployment-contract", "node", ["tools/web/check-deployment-contract.mjs"]],
|
||||
["deployment-http", "node", ["tools/web/check-deployment-http.mjs"]],
|
||||
["deployment-cache", "node", ["tools/web/check-deployment-cache.mjs"]],
|
||||
["v1-acceptance", "npm", ["--prefix", "web", "run", "release:v1-acceptance"]],
|
||||
["v1-acceptance-evidence", "npm", ["--prefix", "web", "run", "test:v1-acceptance-evidence"]],
|
||||
["v1-acceptance-release-binding", "npm", ["--prefix", "web", "run", "test:v1-acceptance-release-binding"]],
|
||||
["full-e2e", "npm", ["--prefix", "web", "run", "test:e2e", "--", "--workers=1"]],
|
||||
["release-browser", "npm", ["--prefix", "web", "run", "test:browser"]],
|
||||
["release-performance", "npm", ["--prefix", "web", "run", "test:release-performance"]],
|
||||
["release-package", "npm", ["--prefix", "web", "run", "test:release-package"]],
|
||||
["offline-reproducibility", "npm", ["--prefix", "web", "run", "release:offline"]],
|
||||
["deployment-runbook", "npm", ["--prefix", "web", "run", "test:deployment-runbook"]],
|
||||
["upgrade-runbook", "npm", ["--prefix", "web", "run", "test:upgrade-runbook"]],
|
||||
["rollback-runbook", "npm", ["--prefix", "web", "run", "test:rollback-runbook"]],
|
||||
["operations-rehearsal", "npm", ["--prefix", "web", "run", "test:operations-rehearsal"]],
|
||||
["deployed-site", "node", ["tools/web/check-deployed-web-blender.mjs"]],
|
||||
];
|
||||
const records = [];
|
||||
for (const [id, command, args] of commands) {
|
||||
const started = Date.now();
|
||||
const result = spawnSync(command, args, { cwd: root, encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1", BLENDER_BIN: process.env.BLENDER_BIN ?? "build_blender_native/bin/blender" }, maxBuffer: 64 * 1024 * 1024 });
|
||||
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
||||
const logPath = path.join(logRoot, `${String(records.length + 1).padStart(2, "0")}-${id}.log`);
|
||||
fs.writeFileSync(logPath, `${output}\n`);
|
||||
records.push({ id, command: [command, ...args].join(" "), exitCode: result.status ?? 1, durationMs: Date.now() - started, log: { path: path.relative(root, logPath).replaceAll(path.sep, "/"), sha256: sha(logPath), bytes: fs.statSync(logPath).size } });
|
||||
process.stdout.write(`[${records.length}/${commands.length}] ${id} exit=${records.at(-1).exitCode} durationMs=${records.at(-1).durationMs}\n`);
|
||||
}
|
||||
const inventory = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/WBV2/dynamic-inventory.json"), "utf8"));
|
||||
const catalog = JSON.parse(fs.readFileSync(path.join(root, "web/app/src/capabilities/blender-feature-route-catalog.json"), "utf8"));
|
||||
const sourceParityStatus = inventory.coverage?.unverifiedUnits === 0 && catalog.unclassifiedCount === 0 ? "READY" : "PARTIAL";
|
||||
const webProductStatus = records.filter((item) => ["context", "governance", "inventory", "dynamic-inventory", "completeness", "feature-smoke-evidence", "submodule-smoke-evidence", "typecheck", "lint", "unit", "local-deps", "source-sync", "full-e2e", "release-browser"].includes(item.id)).every((item) => item.exitCode === 0) ? "READY" : "BLOCKED";
|
||||
const releaseStatus = records.filter((item) => ["deployment-contract", "deployment-http", "deployment-cache", "release-package", "offline-reproducibility", "deployment-runbook", "upgrade-runbook", "rollback-runbook", "operations-rehearsal", "deployed-site"].includes(item.id)).every((item) => item.exitCode === 0) ? "READY" : "BLOCKED";
|
||||
const report = { schemaVersion: 1, reportId: "WBV2-FULL-SOURCE-PARITY-VALIDATION-001", generatedAt: new Date().toISOString(), sourceParityStatus, webProductStatus, releaseStatus, inventory: { unitCount: inventory.summary.unitCount, verifiedUnits: inventory.coverage.verifiedUnits, unverifiedUnits: inventory.coverage.unverifiedUnits, sourceTreeSha256: catalog.sourceTreeSha256, classifiedCount: catalog.classifiedCount, unclassifiedCount: catalog.unclassifiedCount }, records, policy: `route classification is not behavioral completion; ${inventory.coverage.unverifiedUnits} unverified source units remain explicit` };
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`web-source-parity-validation sourceParity=${sourceParityStatus} webProduct=${webProductStatus} release=${releaseStatus} report=${reportPath}\n`);
|
||||
process.exit(records.every((item) => item.exitCode === 0) ? 0 : 1);
|
||||
Reference in New Issue
Block a user