Files
goodbuddy/build/aggregate-release.cjs
T
mesalogo f95acdc000
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
fix: restore packaged runtimes and ship macOS DMG only
Desktop 0.12.7 omitted OpenCode's offline node_modules because the resource
copier excludes node_modules at its source root. Copy from the parent and
verify every prepared configuration file in the final package. Bundle DSH
session projection with the unpacked Host instead of leaving an ASAR-only
external import, and reject external DeepSeek imports in shipped chunks.

Run packaged OpenCode plugin import and the real DSH UtilityProcess handshake
in Windows x64 CI before removing unpacked output. Add regressions using the
actual electron-builder copier and Vite externalization configuration.

Prepare approved Desktop 0.12.8 notes and change macOS defaults and CI to DMG
only. Update aggregation, website downloads and update readers for 12
installers and 20 assets. Current readers retain historical DMG/ZIP support;
the user accepts that older macOS GitHub readers and all older mirror readers
need manual upgrades. Agent remains 0.11.21, with no new Agent or LoongArch tag.

Validation: 3608 tests passed, 0 failed, 61 skipped across 339 files;
typecheck, lint, release-note validation, website static checks and 18 website
tests passed. No local production build or packaged-app launch was performed
at the user's request; actual packaged startup validation runs in CI.

Release note: 修复安装后 OpenCode 离线依赖缺失和 DeepSeek Harness Host 启动退出;macOS 改为仅提供 DMG,受影响旧版用户需从下载页手动升级。
2026-09-08 14:17:38 +08:00

343 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'] },
{ platform: 'macos', arch: 'arm64', formats: ['dmg'] },
{ platform: 'linux', arch: 'x64', formats: ['AppImage', 'deb', 'rpm'] },
{ platform: 'linux', arch: 'arm64', formats: ['AppImage', 'deb', 'rpm'] }
]
const allowedExtensions = {
nsis: '.exe',
portable: '.zip',
dmg: '.dmg',
zip: '.zip',
AppImage: '.AppImage',
deb: '.deb',
rpm: '.rpm'
}
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\.zip$/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,
expectedFormatForFile,
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
})
}