287 lines
12 KiB
JavaScript
287 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash } from "node:crypto";
|
|
import { createRequire } from "node:module";
|
|
import { spawnSync } from "node:child_process";
|
|
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const platformRoot = resolve(scriptDir, "../..");
|
|
const packageRoot = resolve(platformRoot, "packages/n8n-nodes-ndc");
|
|
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
|
const requireModule = createRequire(import.meta.url);
|
|
const expectedPackageVersion = "0.1.2";
|
|
const [patchId = "n8n-nodes-ndc-release-20260716-003", ...extra] = process.argv.slice(2);
|
|
const expectedRuntimeNodes = [
|
|
{
|
|
file: "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
|
|
exportName: "NdcDataProductPublish",
|
|
name: "ndcDataProductPublish",
|
|
},
|
|
{
|
|
file: "dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
|
|
exportName: "NdcDataProductRead",
|
|
name: "ndcDataProductRead",
|
|
},
|
|
{
|
|
file: "dist/nodes/NdcFoundryBinding/NdcFoundryBinding.node.js",
|
|
exportName: "NdcFoundryBinding",
|
|
name: "ndcFoundryBinding",
|
|
},
|
|
];
|
|
const expectedN8nNodes = expectedRuntimeNodes.map((node) => node.file);
|
|
const expectedRuntimeNodeTypes = expectedRuntimeNodes.map((node) => `n8n-nodes-ndc.${node.name}`);
|
|
const expectedN8nCredentials = [
|
|
"dist/credentials/NdcDataProductWriterApi.credentials.js",
|
|
"dist/credentials/NdcDataProductReaderApi.credentials.js",
|
|
"dist/credentials/NdcFoundryBindingApi.credentials.js",
|
|
];
|
|
const expectedCredentialTypes = [
|
|
"ndcDataProductWriterApi",
|
|
"ndcDataProductReaderApi",
|
|
"ndcFoundryBindingApi",
|
|
];
|
|
|
|
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
|
throw new Error("usage: build-n8n-private-extension-artifact.mjs [patch-id]");
|
|
}
|
|
|
|
const packageJson = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
|
|
assertPackageSourcePolicy(packageJson);
|
|
run("npm", ["test"], packageRoot);
|
|
run("npm", ["run", "lint"], packageRoot);
|
|
assertRuntimeNodePolicy(packageJson);
|
|
|
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-n8n-private-extension-"));
|
|
const packDir = join(stage, "pack");
|
|
const payload = join(stage, "payload");
|
|
|
|
try {
|
|
await mkdir(packDir, { recursive: true });
|
|
const pack = run("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", packDir], packageRoot);
|
|
const packResult = JSON.parse(pack.stdout);
|
|
if (!Array.isArray(packResult) || packResult.length !== 1) throw new Error("npm_pack_result_invalid");
|
|
const metadata = packResult[0];
|
|
assertPackedFilePolicy(metadata, packageJson);
|
|
|
|
const packedPath = join(packDir, metadata.filename);
|
|
const canonicalPackedPath = join(packDir, "n8n-nodes-ndc.canonical.tgz");
|
|
const canonicalPackageScript = [
|
|
"import gzip, io, sys, tarfile",
|
|
"members = []",
|
|
"with tarfile.open(sys.argv[1], 'r:gz') as source:",
|
|
" for original in source:",
|
|
" if not (original.isfile() or original.isdir()):",
|
|
" raise SystemExit('unsupported npm package member')",
|
|
" content = b''",
|
|
" if original.isfile():",
|
|
" extracted = source.extractfile(original)",
|
|
" if extracted is None:",
|
|
" raise SystemExit('unreadable npm package member')",
|
|
" content = extracted.read()",
|
|
" members.append((original.name, original.isdir(), content))",
|
|
"with open(sys.argv[2], 'wb') as output:",
|
|
" with gzip.GzipFile(filename='', mode='wb', fileobj=output, compresslevel=9, mtime=0) as compressed:",
|
|
" with tarfile.open(fileobj=compressed, mode='w', format=tarfile.PAX_FORMAT) as target:",
|
|
" for name, is_dir, content in sorted(members, key=lambda item: item[0]):",
|
|
" member = tarfile.TarInfo(name)",
|
|
" member.uid = member.gid = 0",
|
|
" member.uname = member.gname = 'root'",
|
|
" member.mtime = 0",
|
|
" member.mode = 0o755 if is_dir else 0o644",
|
|
" member.type = tarfile.DIRTYPE if is_dir else tarfile.REGTYPE",
|
|
" member.size = 0 if is_dir else len(content)",
|
|
" target.addfile(member, None if is_dir else io.BytesIO(content))",
|
|
].join("\n");
|
|
run("python3", ["-c", canonicalPackageScript, packedPath, canonicalPackedPath], stage);
|
|
const packageBytes = await readFile(canonicalPackedPath);
|
|
const packageSha256 = createHash("sha256").update(packageBytes).digest("hex");
|
|
const releaseId = `${packageJson.version}-${packageSha256.slice(0, 16)}`;
|
|
const relativeReleasePath = `releases/n8n-nodes-ndc/${releaseId}`;
|
|
const releaseDir = join(payload, relativeReleasePath);
|
|
await mkdir(releaseDir, { recursive: true });
|
|
await cp(canonicalPackedPath, join(releaseDir, "package.tgz"), { force: false });
|
|
|
|
const rollbackBaselinePolicy = {
|
|
allowed: [
|
|
"previous_verified_immutable_release",
|
|
"verified_inactive",
|
|
],
|
|
firstActivation: "verified_inactive",
|
|
requiresPreActivationVerification: true,
|
|
};
|
|
const release = {
|
|
schemaVersion: "nodedc.n8n-private-extension-release/v2",
|
|
releaseId,
|
|
package: {
|
|
name: "n8n-nodes-ndc",
|
|
version: packageJson.version,
|
|
sha256: packageSha256,
|
|
bytes: packageBytes.byteLength,
|
|
runtimeTypePrefix: "n8n-nodes-ndc.",
|
|
},
|
|
storage: {
|
|
relativePath: relativeReleasePath,
|
|
immutable: true,
|
|
},
|
|
activation: {
|
|
owner: "engine",
|
|
status: "blocked_pending_engine_owned_mount",
|
|
requiredCommunityPackagePath: "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc",
|
|
requiresAtomicReleaseSwitch: true,
|
|
requiresAllN8nProcessesRestart: true,
|
|
requiresMcpSchemaAcceptance: true,
|
|
rollbackBaselinePolicy,
|
|
},
|
|
};
|
|
const rollback = {
|
|
schemaVersion: "nodedc.n8n-private-extension-rollback/v2",
|
|
releaseId,
|
|
packageSha256,
|
|
mode: "engine-owned-atomic-release-switch",
|
|
baselinePolicy: rollbackBaselinePolicy,
|
|
steps: [
|
|
"select_verified_previous_release_or_preverified_inactive_baseline",
|
|
"switch_engine_owned_mount_atomically",
|
|
"restart_all_n8n_processes",
|
|
"verify_mcp_schema_state_matches_selected_baseline",
|
|
],
|
|
forbidden: [
|
|
"delete_active_release",
|
|
"mutate_engine_core",
|
|
"live_npm_install",
|
|
],
|
|
};
|
|
await writeFile(join(releaseDir, "release.json"), `${JSON.stringify(release, null, 2)}\n`, "utf8");
|
|
await writeFile(join(releaseDir, "rollback.json"), `${JSON.stringify(rollback, null, 2)}\n`, "utf8");
|
|
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=n8n-private-extension\ntype=app-overlay\n`, "utf8");
|
|
await writeFile(join(stage, "files.txt"), `${relativeReleasePath}\n`, "utf8");
|
|
|
|
await mkdir(artifactDir, { recursive: true });
|
|
const target = join(artifactDir, `nodedc-n8n-private-extension-${patchId}.tgz`);
|
|
const tarScript = [
|
|
"import gzip, os, pathlib, sys, tarfile",
|
|
"root = pathlib.Path(sys.argv[2])",
|
|
"def clean(info):",
|
|
" info.uid = info.gid = 0",
|
|
" info.uname = info.gname = 'root'",
|
|
" info.mtime = 0",
|
|
" info.mode = 0o755 if info.isdir() else 0o644",
|
|
" return info",
|
|
"with open(sys.argv[1], 'wb') as output:",
|
|
" with gzip.GzipFile(filename='', mode='wb', fileobj=output, compresslevel=9, mtime=0) as compressed:",
|
|
" with tarfile.open(fileobj=compressed, mode='w', format=tarfile.PAX_FORMAT) as archive:",
|
|
" for top in ('manifest.env', 'files.txt', 'payload'):",
|
|
" path = root / top",
|
|
" archive.add(path, arcname=top, recursive=False, filter=clean)",
|
|
" if path.is_dir():",
|
|
" for child in sorted(path.rglob('*'), key=lambda item: item.as_posix()):",
|
|
" archive.add(child, arcname=child.relative_to(root).as_posix(), recursive=False, filter=clean)",
|
|
].join("\n");
|
|
run("python3", ["-c", tarScript, target, stage], stage);
|
|
|
|
const artifactSha256 = createHash("sha256").update(await readFile(target)).digest("hex");
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
patchId,
|
|
artifact: target,
|
|
artifactSha256,
|
|
releaseId,
|
|
packageSha256,
|
|
nodeTypes: expectedRuntimeNodeTypes,
|
|
credentialTypes: expectedCredentialTypes,
|
|
activation: "blocked_pending_engine_owned_mount",
|
|
}, null, 2));
|
|
} finally {
|
|
await rm(stage, { recursive: true, force: true });
|
|
}
|
|
|
|
function assertPackageSourcePolicy(value) {
|
|
if (value.name !== "n8n-nodes-ndc") throw new Error("package_name_invalid");
|
|
if (value.version !== expectedPackageVersion) throw new Error("package_version_invalid");
|
|
if (value.private !== true) throw new Error("package_must_remain_private");
|
|
if (value.dependencies !== undefined) throw new Error("runtime_dependencies_forbidden");
|
|
for (const lifecycle of ["preinstall", "install", "postinstall", "prepack", "prepare", "postpack"]) {
|
|
if (value.scripts?.[lifecycle] !== undefined) throw new Error(`lifecycle_script_forbidden:${lifecycle}`);
|
|
}
|
|
assertExactRegistration(value.n8n?.nodes, expectedN8nNodes, "n8n_nodes");
|
|
assertExactRegistration(value.n8n?.credentials, expectedN8nCredentials, "n8n_credentials");
|
|
}
|
|
|
|
function assertRuntimeNodePolicy(packageJson) {
|
|
const observedTypes = [];
|
|
for (const expected of expectedRuntimeNodes) {
|
|
const loaded = requireModule(join(packageRoot, expected.file));
|
|
const NodeClass = loaded?.[expected.exportName];
|
|
if (typeof NodeClass !== "function") throw new Error(`runtime_node_export_missing:${expected.exportName}`);
|
|
const description = new NodeClass()?.description;
|
|
if (!description || description.name !== expected.name) {
|
|
throw new Error(`runtime_node_name_mismatch:${expected.exportName}`);
|
|
}
|
|
if ("usableAsTool" in description) {
|
|
throw new Error(`runtime_tool_variant_forbidden:${expected.name}`);
|
|
}
|
|
observedTypes.push(`${packageJson.name}.${description.name}`);
|
|
}
|
|
if (JSON.stringify(observedTypes) !== JSON.stringify(expectedRuntimeNodeTypes)) {
|
|
throw new Error("runtime_node_types_mismatch");
|
|
}
|
|
}
|
|
|
|
function assertExactRegistration(actual, expected, label) {
|
|
if (!Array.isArray(actual)) throw new Error(`${label}_missing`);
|
|
if (actual.length !== new Set(actual).size) throw new Error(`${label}_duplicate`);
|
|
if (actual.length !== expected.length || expected.some((value) => !actual.includes(value))) {
|
|
throw new Error(`${label}_mismatch`);
|
|
}
|
|
}
|
|
|
|
function assertPackedFilePolicy(metadata, sourcePackage) {
|
|
if (metadata.name !== sourcePackage.name || metadata.version !== sourcePackage.version) {
|
|
throw new Error("npm_pack_identity_mismatch");
|
|
}
|
|
if (!Number.isSafeInteger(metadata.size) || metadata.size < 1024 || metadata.size > 32 * 1024 * 1024) {
|
|
throw new Error("npm_pack_size_invalid");
|
|
}
|
|
if (!Array.isArray(metadata.files) || metadata.files.length > 512) throw new Error("npm_pack_file_list_invalid");
|
|
const paths = new Set();
|
|
for (const file of metadata.files) {
|
|
if (!file || typeof file.path !== "string" || paths.has(file.path)) throw new Error("npm_pack_file_invalid");
|
|
paths.add(file.path);
|
|
if (!(file.path === "README.md" || file.path === "package.json" || file.path.startsWith("dist/"))) {
|
|
throw new Error(`npm_pack_path_forbidden:${file.path}`);
|
|
}
|
|
const parts = file.path.split("/");
|
|
if (
|
|
file.path.includes("\\")
|
|
|| parts.some((part) => !part || part === "." || part === ".." || part.startsWith("."))
|
|
|| file.mode !== 0o644
|
|
) {
|
|
throw new Error(`npm_pack_path_unsafe:${file.path}`);
|
|
}
|
|
}
|
|
assertExactRegistration(
|
|
[...paths].filter((value) => /^dist\/nodes\/.+\.node\.js$/.test(value)),
|
|
expectedN8nNodes,
|
|
"npm_pack_nodes",
|
|
);
|
|
assertExactRegistration(
|
|
[...paths].filter((value) => /^dist\/credentials\/.+\.credentials\.js$/.test(value)),
|
|
expectedN8nCredentials,
|
|
"npm_pack_credentials",
|
|
);
|
|
for (const registered of [...sourcePackage.n8n.nodes, ...sourcePackage.n8n.credentials]) {
|
|
if (!paths.has(registered)) throw new Error(`npm_pack_registration_missing:${registered}`);
|
|
}
|
|
}
|
|
|
|
function run(command, args, cwd) {
|
|
const result = spawnSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
if (result.status !== 0) {
|
|
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
|
}
|
|
return result;
|
|
}
|