#!/usr/bin/env node import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; const root = process.cwd(); const args = process.argv.slice(2); function argValue(name) { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; } function hasArg(name) { return args.includes(name); } if (hasArg('--help') || hasArg('-h')) { console.log(`Usage: node scripts/build-release-artifact.mjs --release-id [--out-dir dist/releases] [--skip-build] [--check] [--allow-non-linux]`); process.exit(0); } const releaseId = argValue('--release-id') || process.env.RELEASE_ID; if (!releaseId || !/^[A-Za-z0-9._-]+$/.test(releaseId)) { throw new Error('Missing or invalid release id. Use --release-id .'); } const outDir = path.resolve(root, argValue('--out-dir') || 'dist/releases'); const stagingDir = path.resolve(root, '.release-artifacts', 'staging', releaseId); const checkOnly = hasArg('--check'); const skipBuild = hasArg('--skip-build'); const allowNonLinux = hasArg('--allow-non-linux'); const copyEntries = [ ['package.json', 'package.json', true], ['pnpm-lock.yaml', 'pnpm-lock.yaml', true], ['node_modules', 'node_modules', true], ['prisma/schema.prisma', 'prisma/schema.prisma', true], ['prisma/migrations', 'prisma/migrations', true], ['apps/api/package.json', 'apps/api/package.json', true], ['apps/api/dist', 'apps/api/dist', true], ['apps/api/node_modules', 'apps/api/node_modules', true], ['apps/web/package.json', 'apps/web/package.json', true], ['apps/web/dist', 'public', true], ['apps/web/node_modules', 'apps/web/node_modules', true], ['apps/worker-cdr/package.json', 'apps/worker-cdr/package.json', true], ['apps/worker-cdr/dist', 'apps/worker-cdr/dist', true], ['apps/worker-cdr/node_modules', 'apps/worker-cdr/node_modules', true], ['apps/worker-recording/package.json', 'apps/worker-recording/package.json', true], ['apps/worker-recording/dist', 'apps/worker-recording/dist', true], ['apps/worker-recording/node_modules', 'apps/worker-recording/node_modules', true], ['apps/worker-config-publisher/package.json', 'apps/worker-config-publisher/package.json', true], ['apps/worker-config-publisher/dist', 'apps/worker-config-publisher/dist', true], ['apps/worker-config-publisher/node_modules', 'apps/worker-config-publisher/node_modules', true], ['packages/auth/package.json', 'packages/auth/package.json', true], ['packages/auth/dist', 'packages/auth/dist', true], ['packages/auth/vendor/argon2id/dist', 'packages/auth/vendor/argon2id/dist', true], ['packages/contracts/package.json', 'packages/contracts/package.json', true], ['packages/contracts/dist', 'packages/contracts/dist', true], ['packages/database/package.json', 'packages/database/package.json', true], ['packages/database/dist', 'packages/database/dist', true], ['packages/database/node_modules', 'packages/database/node_modules', true], ['packages/domain/package.json', 'packages/domain/package.json', true], ['packages/domain/dist', 'packages/domain/dist', true], ['packages/observability/package.json', 'packages/observability/package.json', true], ['packages/observability/dist', 'packages/observability/dist', true], ['packages/observability/node_modules', 'packages/observability/node_modules', true], ['packages/redis/package.json', 'packages/redis/package.json', true], ['packages/redis/dist', 'packages/redis/dist', true], ['packages/redis/node_modules', 'packages/redis/node_modules', true], ['infra/server-b/s30', 'infra/server-b/s30', true], ['infra/server-a/s28/lisglosips_hotpath.lua', 'infra/server-a/s28/lisglosips_hotpath.lua', true], ['scripts/phase2-gateway-migration.mjs', 'scripts/phase2-gateway-migration.mjs', true] ]; function run(command, commandArgs, options = {}) { const result = spawnSync(command, commandArgs, { cwd: root, stdio: options.stdio || 'pipe', encoding: 'utf8' }); if (result.status !== 0) { const output = [result.stdout, result.stderr].filter(Boolean).join('\n'); throw new Error(`${command} ${commandArgs.join(' ')} failed\n${output}`); } return (result.stdout || '').trim(); } async function sha256(filePath) { const hash = createHash('sha256'); hash.update(await fs.readFile(filePath)); return hash.digest('hex'); } async function copyEntry(source, target) { const sourcePath = path.resolve(root, source); const targetPath = path.resolve(stagingDir, target); await fs.mkdir(path.dirname(targetPath), { recursive: true }); await fs.rm(targetPath, { recursive: true, force: true }); if (process.platform === 'linux') { run('cp', ['-a', sourcePath, targetPath]); return; } await fs.cp(sourcePath, targetPath, { recursive: true, verbatimSymlinks: true }); } async function collectFiles(dir, prefixPath = '') { const entries = await fs.readdir(dir, { withFileTypes: true }); const files = []; for (const entry of entries) { const relative = path.join(prefixPath, entry.name); if (entry.isDirectory()) { files.push(...await collectFiles(dir, relative)); } else if (entry.isFile()) { files.push(relative.replaceAll(path.sep, '/')); } } return files; } if (process.platform !== 'linux' && !allowNonLinux && !checkOnly) { throw new Error('Release artifacts should be built on Linux for B-server runtime compatibility. Re-run with --allow-non-linux only for a local inspection artifact.'); } if (checkOnly) { const missing = copyEntries.filter(([source, , required]) => required && !existsSync(path.resolve(root, source))); if (missing.length > 0) { throw new Error(`Missing required release paths:\n${missing.map(([source]) => `- ${source}`).join('\n')}`); } console.log(`Release artifact check passed for ${releaseId}. ${copyEntries.length} required entries are present.`); process.exit(0); } if (!skipBuild) { run(process.execPath, ['scripts/pnpm-run.mjs', '-r', 'build'], { stdio: 'inherit' }); } const missing = copyEntries.filter(([source, , required]) => required && !existsSync(path.resolve(root, source))); if (missing.length > 0) { throw new Error(`Missing required release paths:\n${missing.map(([source]) => `- ${source}`).join('\n')}`); } await fs.rm(stagingDir, { recursive: true, force: true }); await fs.mkdir(stagingDir, { recursive: true }); for (const [source, target] of copyEntries) { await copyEntry(source, target); } const manifest = { releaseId, createdAt: new Date().toISOString(), gitCommit: run('git', ['rev-parse', 'HEAD']), gitStatus: run('git', ['status', '--short']).split('\n').filter(Boolean), node: process.version, pnpm: run(process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', ['--version']), platform: process.platform, arch: process.arch, requiredEntries: copyEntries.map(([source, target]) => ({ source, target })) }; await fs.writeFile(path.join(stagingDir, 'RELEASE_MANIFEST.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); await fs.mkdir(outDir, { recursive: true }); const tarPath = path.join(outDir, `${releaseId}.tar.gz`); run('tar', ['-czf', tarPath, '-C', stagingDir, '.'], { stdio: 'inherit' }); const fileCount = process.platform === 'linux' ? Number(run('bash', ['-lc', `find ${JSON.stringify(stagingDir)} -type f | wc -l`])) : (await collectFiles(stagingDir)).length; const artifactHash = await sha256(tarPath); await fs.writeFile(path.join(outDir, `${releaseId}.sha256`), `${artifactHash} ${path.basename(tarPath)}\n`, 'utf8'); console.log(`Release artifact created: ${tarPath}`); console.log(`SHA256: ${artifactHash}`); console.log(`Files staged: ${fileCount}`);