const { copyFileSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } = require('node:fs') const { basename, dirname, isAbsolute, join, relative, resolve } = require('node:path') const { sha256File } = require('./file-hash.cjs') const root = join(__dirname, '..') const packageJson = JSON.parse( readFileSync(join(root, 'package.json'), 'utf8') ) const productName = packageJson.build?.productName ?? packageJson.name const manifestName = 'release-manifest.json' const targetDefinitions = [ { platform: 'windows', arch: 'x64', formats: ['nsis', 'portable'] }, { platform: 'windows', arch: 'arm64', formats: ['nsis', 'portable'] }, { platform: 'macos', arch: 'x64', formats: ['dmg', 'zip'] }, { platform: 'macos', arch: 'arm64', formats: ['dmg', 'zip'] }, { platform: 'linux', arch: 'x64', formats: ['AppImage', 'deb'] }, { platform: 'linux', arch: 'arm64', formats: ['AppImage', 'deb'] } ] const allowedExtensions = { nsis: '.exe', portable: '.exe', dmg: '.dmg', zip: '.zip', AppImage: '.AppImage', deb: '.deb' } function parseArguments(argv) { const options = {} for (let index = 0; index < argv.length; index += 1) { const argument = argv[index] if (argument === '--input' || argument === '--output') { const value = argv[index + 1] if (!value) { throw new Error(`${argument} 缺少值`) } options[argument.slice(2)] = resolve(value) index += 1 } else { throw new Error(`未知参数:${argument}`) } } if (!options.input || !options.output) { throw new Error('必须指定 --input 和 --output') } return options } function assertPlainFile(filePath, description) { const status = lstatSync(filePath, { throwIfNoEntry: false }) if (!status?.isFile() || status.isSymbolicLink()) { throw new Error(`${description}必须是普通文件:${filePath}`) } } function assertSafeName(name, description) { if ( typeof name !== 'string' || name.length === 0 || isAbsolute(name) || basename(name) !== name || name === '.' || name === '..' || name.includes('/') || name.includes('\\') || name.includes('\0') ) { throw new Error(`${description}包含不安全路径:${String(name)}`) } } function readManifest(directory) { const filePath = join(directory, manifestName) assertPlainFile(filePath, '平台 manifest') let manifest try { manifest = JSON.parse(readFileSync(filePath, 'utf8')) } catch (error) { throw new Error(`无法解析平台 manifest:${filePath}`, { cause: error }) } return { filePath, manifest } } function assertManifest(manifest, expected) { if ( !manifest || manifest.formatVersion !== 1 || manifest.productName !== productName || manifest.version !== packageJson.version || manifest.platform !== expected.platform || manifest.arch !== expected.arch || !Array.isArray(manifest.formats) || manifest.formats.length !== expected.formats.length || !expected.formats.every( (format, index) => manifest.formats[index] === format ) || !Array.isArray(manifest.files) ) { throw new Error( `平台 manifest 元数据错误:${expected.platform}-${expected.arch}` ) } } function expectedFormatForFile(name, target) { if (target.platform === 'windows') { if (/-setup\.exe$/u.test(name)) { return 'nsis' } if (/-portable\.exe$/u.test(name)) { return 'portable' } return undefined } return target.formats.find((format) => name.endsWith(allowedExtensions[format]) ) } function listTargetDirectories(inputDirectory) { if (lstatSync(inputDirectory).isSymbolicLink()) { throw new Error(`拒绝符号链接:${inputDirectory}`) } const inputRoot = realpathSync(inputDirectory) return readdirSync(inputRoot, { withFileTypes: true }) .map((entry) => { if (entry.isSymbolicLink()) { throw new Error(`拒绝符号链接:${join(inputRoot, entry.name)}`) } if (!entry.isDirectory()) { throw new Error(`下载目录只能包含目标目录:${entry.name}`) } const directory = realpathSync(join(inputRoot, entry.name)) const pathFromRoot = relative(inputRoot, directory) if ( pathFromRoot.startsWith('..') || isAbsolute(pathFromRoot) ) { throw new Error(`目标目录越出输入目录:${directory}`) } return directory }) } async function aggregateRelease(inputDirectory, outputDirectory) { const resolvedInput = resolve(inputDirectory) const resolvedOutput = resolve(outputDirectory) const outputFromInput = relative(resolvedInput, resolvedOutput) const inputFromOutput = relative(resolvedOutput, resolvedInput) if ( outputFromInput === '' || (!outputFromInput.startsWith('..') && !isAbsolute(outputFromInput)) || (!inputFromOutput.startsWith('..') && !isAbsolute(inputFromOutput)) ) { throw new Error('输入目录和上传目录必须相互独立') } const directories = listTargetDirectories(inputDirectory) if (directories.length !== targetDefinitions.length) { throw new Error( `发布目标数量错误:期望 ${targetDefinitions.length},实际 ${directories.length}` ) } const manifests = directories.map(readManifest) const byTarget = new Map() for (const item of manifests) { const key = `${item.manifest.platform}-${item.manifest.arch}` if (byTarget.has(key)) { throw new Error(`发布目标重复:${key}`) } byTarget.set(key, item) } const fileNames = new Set() const targets = [] mkdirSync(outputDirectory, { recursive: false }) for (const expected of targetDefinitions) { const key = `${expected.platform}-${expected.arch}` const item = byTarget.get(key) if (!item) { throw new Error(`缺少发布目标:${key}`) } assertManifest(item.manifest, expected) const directory = dirname(item.filePath) const entries = readdirSync(directory, { withFileTypes: true }) for (const entry of entries) { if (entry.isSymbolicLink()) { throw new Error(`拒绝符号链接:${join(directory, entry.name)}`) } if (!entry.isFile()) { throw new Error(`目标目录只能包含普通文件:${entry.name}`) } } if (entries.length !== item.manifest.files.length + 1) { throw new Error(`目标目录包含 manifest 未声明的文件:${key}`) } const seenFormats = new Set() const files = [] for (const file of item.manifest.files) { assertSafeName(file?.name, '发布文件名') if ( !Number.isSafeInteger(file.size) || file.size < 1 || typeof file.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(file.sha256) ) { throw new Error(`发布文件元数据错误:${file?.name ?? key}`) } const format = expectedFormatForFile(file.name, expected) if (format && seenFormats.has(format)) { throw new Error(`发布文件格式或数量错误:${file.name}`) } const source = join(directory, file.name) assertPlainFile(source, '发布文件') const actualSize = lstatSync(source).size const actualHash = await sha256File(source) if (actualSize !== file.size || actualHash !== file.sha256) { throw new Error(`发布文件完整性校验失败:${file.name}`) } if (!format) { continue } if (fileNames.has(file.name)) { throw new Error(`发布文件名全局重复:${file.name}`) } assertPlainFile(source, '发布文件') copyFileSync(source, join(outputDirectory, file.name)) fileNames.add(file.name) seenFormats.add(format) files.push({ name: file.name, size: file.size, sha256: file.sha256 }) } if ( expected.formats.some((format) => !seenFormats.has(format)) ) { throw new Error(`发布目标格式不完整:${key}`) } const renamedManifest = `release-manifest-${key}.json` writeFileSync( join(outputDirectory, renamedManifest), `${JSON.stringify({ ...item.manifest, files }, null, 2)}\n`, 'utf8' ) targets.push({ platform: expected.platform, arch: expected.arch, formats: [...expected.formats], manifest: renamedManifest, files }) } if (byTarget.size !== targetDefinitions.length) { throw new Error('包含未知发布目标') } const aggregateManifest = { formatVersion: 1, productName, version: packageJson.version, targets, files: targets.flatMap((target) => target.files.map((file) => ({ platform: target.platform, arch: target.arch, ...file })) ) } writeFileSync( join(outputDirectory, manifestName), `${JSON.stringify(aggregateManifest, null, 2)}\n`, 'utf8' ) const checksumNames = readdirSync(outputDirectory) .sort((left, right) => left.localeCompare(right)) const checksums = [] for (const name of checksumNames) { assertSafeName(name, '上传文件名') const filePath = join(outputDirectory, name) assertPlainFile(filePath, '上传文件') checksums.push(`${await sha256File(filePath)} ${name}`) } writeFileSync( join(outputDirectory, 'SHA256SUMS'), `${checksums.join('\n')}\n`, 'utf8' ) return aggregateManifest } async function main(argv = process.argv.slice(2)) { const options = parseArguments(argv) await aggregateRelease(options.input, options.output) console.log(`发布资产聚合完成:${options.output}`) } module.exports = { aggregateRelease, assertSafeName, parseArguments, targetDefinitions } if (require.main === module) { main().catch((error) => { console.error(error) if (process.env.GITHUB_ACTIONS === 'true') { const message = String(error?.message ?? error) .replaceAll('%', '%25') .replaceAll('\r', '%0D') .replaceAll('\n', '%0A') console.error( `::error title=Release aggregation failed::${message}` ) } process.exitCode = 1 }) }