import { execFileSync } from 'node:child_process'; import { createHash, createSign } from 'node:crypto'; import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(SCRIPT_DIR, '..'); const DEFAULT_BUILD_DIR = path.join(REPO_ROOT, 'build'); const DEFAULT_OUT_DIR = path.join(REPO_ROOT, 'release-assets'); const MANIFEST_SCHEMA = 'bitsocial.release-manifest.v1'; const SIGNATURE_SCHEMA = 'bitsocial.release-manifest-signature.v1'; const SIGNATURE_ALGORITHM = 'ECDSA-P256-SHA256'; const DEFAULT_KEY_ID = '5chan-release-p256-2026-05'; const MANIFEST_FILE = '5chan-release-manifest.json'; const SIGNATURE_FILE = '5chan-release-manifest.sig.json'; const args = new Map(); for (let index = 2; index < process.argv.length; index += 2) { const key = process.argv[index]; const value = process.argv[index + 1]; if (!key?.startsWith('--') || !value) { throw new Error(`Invalid argument near ${key ?? ''}`); } args.set(key, value); } const buildDir = path.resolve(args.get('--build-dir') ?? DEFAULT_BUILD_DIR); const outDir = path.resolve(args.get('--out-dir') ?? DEFAULT_OUT_DIR); const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8')); const version = packageJson.version; const releaseTag = process.env.GITHUB_REF_NAME?.startsWith('v') ? process.env.GITHUB_REF_NAME : `v${version}`; const privateKeyPem = process.env.RELEASE_MANIFEST_PRIVATE_KEY_PEM; const keyId = process.env.RELEASE_MANIFEST_KEY_ID || DEFAULT_KEY_ID; if (!privateKeyPem) { throw new Error('RELEASE_MANIFEST_PRIVATE_KEY_PEM is required to sign the release manifest'); } const files = await listFiles(buildDir); const manifest = { schema: MANIFEST_SCHEMA, appName: '5chan', version, releaseTag, verificationScope: 'web-release-all-files', generatedAt: new Date().toISOString(), sourceCommit: process.env.GITHUB_SHA || readGitCommit(), files, }; const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); const manifestSha256 = sha256Hex(manifestBytes); const signer = createSign('sha256'); signer.update(manifestBytes); signer.end(); const signature = signer.sign({ key: privateKeyPem, dsaEncoding: 'ieee-p1363' }); const signaturePayload = { schema: SIGNATURE_SCHEMA, algorithm: SIGNATURE_ALGORITHM, keyId, manifestSha256, signature: base64Url(signature), }; await mkdir(outDir, { recursive: true }); await writeFile(path.join(outDir, MANIFEST_FILE), manifestBytes); await writeFile(path.join(outDir, SIGNATURE_FILE), `${JSON.stringify(signaturePayload, null, 2)}\n`); console.log(`Wrote ${path.relative(REPO_ROOT, path.join(outDir, MANIFEST_FILE))}`); console.log(`Wrote ${path.relative(REPO_ROOT, path.join(outDir, SIGNATURE_FILE))}`); async function listFiles(rootDir) { const entries = []; async function walk(directory) { for (const entry of await readdir(directory, { withFileTypes: true })) { const absolutePath = path.join(directory, entry.name); if (entry.isDirectory()) { await walk(absolutePath); continue; } if (!entry.isFile()) { continue; } const relativePath = normalizePath(path.relative(rootDir, absolutePath)); if (relativePath === MANIFEST_FILE || relativePath === SIGNATURE_FILE) { continue; } const bytes = await readFile(absolutePath); const fileStat = await stat(absolutePath); entries.push({ path: relativePath, bytes: fileStat.size, sha256: sha256Hex(bytes), }); } } await walk(rootDir); return entries.sort((first, second) => first.path.localeCompare(second.path)); } function normalizePath(value) { return value.split(path.sep).join('/'); } function readGitCommit() { try { return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }).trim(); } catch { return undefined; } } function sha256Hex(bytes) { return createHash('sha256').update(bytes).digest('hex'); } function base64Url(bytes) { return Buffer.from(bytes).toString('base64url'); }